Skip to content

A useForm block that holds the whole document is read with its edits - #254

Merged
AlexSamBY merged 5 commits into
masterfrom
claude/use-form-block-edits
Oct 27, 2026
Merged

AlexSamBY merged 5 commits into
masterfrom
claude/use-form-block-edits

Conversation

@AlexSamBY

@AlexSamBY AlexSamBY commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

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 EPBDS-17018: a useForm table row starts with its values, and a submit sends every row with its edits #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

  • ac79d02 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.
  • 6ef47c6 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 EPBDS-17018: a useForm table row starts with its values, and a submit sends every row with its edits #251's review rounds and of this PR's pass, except the three R2 cases EPBDS-17018: a useForm table row starts with its values, and a submit sends every row with its edits #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

A `Data` view with `useForm` whose `name` or `data` selects no values starts its
form from the document's values, and the document's own form holds them too.
`mergeData` took the first form to register, the block's, as the master and
merged the document's copy, as it started, over the block's edits: they were
missing from `getFormData` and a `showIf`, and a submit sent the rows of a
table in the block as they started. A block shown after an edit of the
document's form took that edit back.

Where more than one form holds the document from its root, each now writes
over the merge the fields it changed, as a `useForm` table row's form does
since #251, and its start moves the same way after a submit.
…ive merge and the kept one

Tests cover the form a `relativePath` places in both, and a merge of the other forms is said to be an object where it always is.
…and a Select's order in such a block is kept

Review found that the rule also took in a block that selects values of its own and a `useForm` view in a row's expanded content, whose fields of the document's names then replaced the document's. `Data` now marks a block that starts from the document's values, and the form layer a tree's root (`wholeDocument`); only those arbitrate. What `fromRoot` changed of such a form's values, a Select's options in the order its value picks, is written over the merge too.
…f the whole document

Review found that a block that selects no values inside a `Data` view without `useForm` that selects a section or a row was marked as holding the whole document, as `instance.data` is that view's values: its fields of the document's names replaced the document's again. `Data` now hands `wholeDocument` down only from a document that holds the whole document. What `fromRoot` gives over the merge is read only where it can change: a key it takes away, and an array it orders anew.
@AlexSamBY
AlexSamBY merged commit bd1bc6d into master Oct 27, 2026
15 of 16 checks passed
@AlexSamBY
AlexSamBY deleted the claude/use-form-block-edits branch October 27, 2026 16:28
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 27, 2026
…the whole document

Since #254 the merges take, from each form that holds the whole document, the fields it
changed (`mergeDocuments`), and `restartWrittenRows` reads such forms that way. Two tests,
each on rows with forms of their own and on rows that share the document's form, in a table
beside such a block: a row's edit through an Add and then a Remove of the row before it, and
an edit kept in its row as a row that reads the same moves into its place, in the field,
`getFormData` and a submit.

Measured on this branch: without the edits written, both fail on rows with forms of their
own; without the starts moved, the second fails; with the starts of the rows put back moved
too, the first fails.
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