diff --git a/CLAUDE.md b/CLAUDE.md
index f1338f0d..1dfee143 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -149,7 +149,12 @@ what the forms holding the document from its root hold of its values after a sub
`submit` action calls as it writes; a submit a row's own document makes writes into the row's form, and moves no
start). The rest of the row, a field its cells only show included, is what the document's form holds: what another
form, such as the row's expanded content's, has edited there, and what a submit has written of the row's form. A
-new write of a row's values into the document's form must move the start the same way. Its fields are every one it
+new write of a row's values into the document's form must move the start the same way. An Add and a Remove write the
+fields such rows changed into the document's form before they add or remove a row (`keepRowEdits`,
+`engine/dataKindPush.ts`): a row whose data that changes starts again from it with its edits, and a row they leave where
+it was, and as it was, goes back into the data as its form started from it, so its form keeps its start and what it
+holds (`settleWrittenRows`); the start of every other row written moves, to what the document's form held of it once
+written, where the row still was. Its fields are every one it
has registered, the ones a `showIf` hid included (`fieldNamesOf` in `createBatchedForm`). A name that comes is no
change of what the forms hold: the kept merge is checked against the values of the names that came
(`fieldNamesCount`, `holdsFields`), so a burst of registrations, a mount's among them, merges nothing more. Where more
diff --git a/src/core/engine/__tests__/formData.test.js b/src/core/engine/__tests__/formData.test.js
index 6922926f..a2198e5d 100644
--- a/src/core/engine/__tests__/formData.test.js
+++ b/src/core/engine/__tests__/formData.test.js
@@ -1,12 +1,15 @@
import {
changeOptionOrderForSelectFields,
+ changedRowsFor,
getFormsData,
getRawFormsData,
getLiveMergedDataKindArray,
holdsFields,
restartWrittenRows,
rowPlaceOf,
+ settleWrittenRows,
} from '../formData'
+import { cloneDeep } from '../../utils'
/**
* A form as the merge reads it: its values, the fields it registers and the values it started with, which a table
@@ -504,6 +507,194 @@ describe('restartWrittenRows', () => {
})
})
+describe('changedRowsFor', () => {
+ // A table row with a form of its own, which starts from the row at its place (`engine/Data.tsx`), and its fields.
+ const rowForm = (values, relativePath, relativeIndex, fields, initialValues) => ({
+ form: { getState: () => ({ values, initialValues }), getRegisteredFields: () => fields },
+ meta: { relativePath, relativeIndex },
+ })
+
+ it('gives each row whose form changed a field, as the document\'s form holds it with those fields written', () => {
+ const document = makeForm({ title: 'T', items: [{ kind: 'a', note: 'new', when: '2020-01-01' }, { kind: 'b' }] })
+ const forms = new Map([
+ ['first', rowForm({ items: [{ kind: 'typed', note: 'old' }] }, 'items', 0, ['items[0].kind', 'items[0].when', 'items[0].note'], { items: [{ kind: 'a', note: 'old', when: '2020-01-01' }] })],
+ ['second', rowForm({ items: [undefined, { kind: 'b' }] }, 'items', 1, ['items[1].kind'], { items: [undefined, { kind: 'b' }] })],
+ ['document', { form: document, meta: {} }],
+ ])
+
+ expect(changedRowsFor(document, forms)).toEqual([{ place: 'items[0]', row: { kind: 'typed', note: 'new' }, initial: undefined }])
+ expect(document.getState().values.items[0]).toEqual({ kind: 'a', note: 'new', when: '2020-01-01' })
+ })
+
+ it('leaves out a row its array does not hold, a row with no field in it, and the form the rows are for', () => {
+ const document = rowForm({ items: [{ kind: 'edited' }] }, 'items', 0, ['items[0].kind'], { items: [{ kind: 'a' }] })
+ const forms = new Map([
+ ['past the end', rowForm({ items: [undefined, { kind: 'typed' }] }, 'items', 1, ['items[1].kind'])],
+ ['elsewhere', rowForm({ others: [{ kind: 'typed' }] }, 'others', 0, ['others[0].kind'])],
+ ['no field in it', rowForm({ items: [{ kind: 'typed' }], kind: 'typed' }, 'items', 0, ['kind'])],
+ ['the document', document],
+ ])
+
+ expect(changedRowsFor(document.form, forms)).toEqual([])
+ })
+
+ it('leaves out a row whose edits the form holds already, as a submit leaves them in the document\'s form', () => {
+ const document = makeForm({ items: [{ kind: 'typed' }] })
+ const forms = new Map([
+ ['row', rowForm({ items: [{ kind: 'typed' }] }, 'items', 0, ['items[0].kind'], { items: [{ kind: 'a' }] })],
+ ['document', { form: document, meta: {} }],
+ ])
+
+ expect(changedRowsFor(document, forms)).toEqual([])
+ })
+
+ it('leaves out a row where the form\'s array has a hole, as a row\'s own form has before its row', () => {
+ const items = []
+ items[1] = { kind: 'b' }
+ const row = rowForm({ items }, 'items', 1, ['items[1].kind'])
+ const forms = new Map([['before it', rowForm({ items: [{ kind: 'typed' }] }, 'items', 0, ['items[0].kind'])], ['the row', row]])
+
+ expect(changedRowsFor(row.form, forms)).toEqual([])
+ })
+
+ it('leaves out a row whose place has another shape in the form, which final-form throws for', () => {
+ const document = makeForm({ orders: { 1: { lines: [{ sku: 'A' }] } } })
+ const forms = new Map([
+ ['line', rowForm({ orders: { 1: { lines: [{ sku: 'B' }] } } }, 'orders.1.lines', 0, ['orders.1.lines[0].sku'])],
+ ['document', { form: document, meta: {} }],
+ ])
+
+ expect(changedRowsFor(document, forms)).toEqual([])
+ })
+
+ it('gives the row a row\'s form started from, where that form holds just it, an object, and the form holds it just so', () => {
+ const started = { kind: 'a' }
+ const values = { items: [{ kind: 'a' }, null, { kind: 'c', note: 'new' }, { kind: 'd' }] }
+ const document = makeForm(values)
+ const forms = new Map([
+ ['as it started', rowForm({ items: [{ kind: 'typed' }] }, 'items', 0, ['items[0].kind'], { items: [started] })],
+ ['empty', rowForm({ items: [undefined, { kind: 'typed' }] }, 'items', 1, ['items[1].kind'], { items: [undefined, null] })],
+ ['edited below', rowForm({ items: [undefined, undefined, { kind: 'typed' }] }, 'items', 2, ['items[2].kind'], { items: [undefined, undefined, { kind: 'c' }] })],
+ // A form that starts from the whole document, as a row whose cells read the document does.
+ ['the whole document', rowForm({ ...values, items: [...values.items.slice(0, 3), { kind: 'typed' }] }, 'items', 3, ['items[3].kind'], values)],
+ ['document', { form: document, meta: {} }],
+ ])
+
+ expect(changedRowsFor(document, forms).map(({ initial }) => initial)).toEqual([started, undefined, undefined, undefined])
+ })
+
+ it('gives no `initial` for a row with a row given inside it that has none, whose value it would take away', () => {
+ const values = { project: 'P', phases: [{ title: 'Design', lines: [{ sku: 'A' }, null] }] }
+ const phase = rowForm({ phases: [{ title: 'Design 2', lines: [{ sku: 'A' }, null] }] }, 'phases', 0, ['phases[0].title'], { phases: [values.phases[0]] })
+ const line = initialValues => rowForm({ phases: [{ lines: [undefined, { sku: 'B' }] }] }, 'phases.0.lines', 1, ['phases.0.lines[1].sku'], initialValues)
+ const initialsOf = (lineStartsFrom, documentValues) => changedRowsFor(makeForm(documentValues), new Map([
+ ['phase', phase], ['line', line(lineStartsFrom)], ['document', { form: makeForm(documentValues), meta: {} }],
+ ])).map(({ initial }) => initial)
+
+ // A `null` line: its form starts from the whole document.
+ expect(initialsOf(values, values)).toEqual([undefined, undefined])
+ // A line whose form starts from its row: the phase goes back, and takes it along.
+ const withLine = { ...values, phases: [{ title: 'Design', lines: [{ sku: 'A' }, { sku: 'B0' }] }] }
+ const phaseOf = rowForm(phase.form.getState().values, 'phases', 0, ['phases[0].title'], { phases: [withLine.phases[0]] })
+ expect(changedRowsFor(makeForm(withLine), new Map([
+ ['phase', phaseOf], ['line', line({ phases: [{ lines: [undefined, { sku: 'B0' }] }] })], ['document', { form: makeForm(withLine), meta: {} }],
+ ])).map(({ initial }) => initial)).toEqual([withLine.phases[0], { sku: 'B0' }])
+ })
+
+ it.each([['inner', ['line', 'phase']], ['outer', ['phase', 'line']]])('gives a row inside another after it, in it, the %s one registered first', (_, order) => {
+ const document = makeForm({ phases: [{ title: 'Design', lines: [{ sku: 'A' }, { sku: 'B' }] }] })
+ const rows = {
+ phase: rowForm({ phases: [{ title: 'Design 2', lines: [{ sku: 'A' }, { sku: 'B' }] }] }, 'phases', 0, ['phases[0].title']),
+ line: rowForm({ phases: [{ lines: [undefined, { sku: 'B2' }] }] }, 'phases.0.lines', 1, ['phases.0.lines[1].sku']),
+ }
+ const forms = new Map([...order.map(name => [name, rows[name]]), ['document', { form: document, meta: {} }]])
+
+ expect(changedRowsFor(document, forms).map(({ place, row }) => [place, row])).toEqual([
+ ['phases[0]', { title: 'Design 2', lines: [{ sku: 'A' }, { sku: 'B2' }] }],
+ ['phases.0.lines[1]', { sku: 'B2' }],
+ ])
+ })
+})
+
+
+describe('settleWrittenRows', () => {
+ // A table row with a form of its own, which starts from the row at its place (`engine/Data.tsx`), and its fields.
+ const rowForm = (values, relativePath, relativeIndex, fields, initialValues) => ({
+ form: { getState: () => ({ values, initialValues }), getRegisteredFields: () => fields },
+ meta: { relativePath, relativeIndex },
+ })
+ /**
+ * Settles `rows` after an Add or a Remove: the document's form holds `json`, the data it rebuilt, and held `written`
+ * once the rows were written into it. Gives what the forms read as then.
+ */
+ const settle = (json, rows, written, rowForms) => {
+ const document = makeForm(json)
+ const forms = new Map([...Object.entries(rowForms), ['document', { form: document, meta: {} }]])
+ settleWrittenRows(json, rows, { form: document, values: written }, forms)
+ return getFormsData(forms)
+ }
+
+ it('puts back a row the operation left where it was and as it was, whose form then gives its edit', () => {
+ const started = { kind: 'a' }
+ const row = rowForm({ items: [{ kind: 'typed' }] }, 'items', 0, ['items[0].kind'], { items: [started] })
+ const json = { items: [{ kind: 'typed' }, { kind: 'b' }] }
+
+ const read = settle(json, [{ place: 'items[0]', row: { kind: 'typed' }, initial: started }], { items: [{ kind: 'typed' }] }, { row })
+
+ expect(json).toEqual({ items: [{ kind: 'a' }, { kind: 'b' }] })
+ expect(read).toEqual({ items: [{ kind: 'typed' }, { kind: 'b' }] })
+ })
+
+ it('leaves a row the operation moved, and measures its form\'s changes from what was written, where it was', () => {
+ // Rows `x`, `a`, `a`, the second edited to `a1`, the first removed: the third, which reads as the second did,
+ // comes to the second's place, and its form is not started again.
+ const row = rowForm({ items: [undefined, { kind: 'a1' }] }, 'items', 1, ['items[1].kind'], { items: [undefined, { kind: 'a' }] })
+ const json = { items: [{ kind: 'a1' }, { kind: 'a' }] }
+
+ const read = settle(json, [{ place: 'items[1]', row: { kind: 'a1' }, initial: { kind: 'a' } }], { items: [{ kind: 'x' }, { kind: 'a1' }, { kind: 'a' }] }, { row })
+
+ expect(json).toEqual({ items: [{ kind: 'a1' }, { kind: 'a' }] })
+ expect(read).toEqual({ items: [{ kind: 'a1' }, { kind: 'a' }] })
+ })
+
+ it('reads another form that holds the document from its root as it is, and moves no start of a row not written', () => {
+ const moved = rowForm({ items: [undefined, { kind: 'a1' }] }, 'items', 1, ['items[1].kind'], { items: [undefined, { kind: 'a' }] })
+ // Edited, and not written: its edit is what the data holds there.
+ const notWritten = rowForm({ items: [{ kind: 'a1' }] }, 'items', 0, ['items[0].kind'], { items: [{ kind: 'x' }] })
+ const json = { items: [{ kind: 'a1' }, { kind: 'a' }] }
+ const document = makeForm(json)
+ const forms = new Map([
+ ['moved', moved], ['not written', notWritten], ['document', { form: document, meta: {} }],
+ // A block's form of its own at the document's root, and one in a row, which holds no document.
+ ['block', { form: makeForm({ title: 'T' }) }], ['in a row', { form: makeForm({ note: 'n' }), meta: { relativePath: 'items.0' } }],
+ ])
+
+ settleWrittenRows(json, [{ place: 'items[1]', row: { kind: 'a1' }, initial: { kind: 'a' } }],
+ { form: document, values: { items: [{ kind: 'x' }, { kind: 'a1' }, { kind: 'a' }] } }, forms)
+
+ forms.delete('in a row')
+ expect(getFormsData(forms)).toEqual({ title: 'T', items: [{ kind: 'a1' }, { kind: 'a' }] })
+ })
+
+ it('puts back a row with the rows inside it, whose forms keep what they measure their changes from', () => {
+ const phase = rowForm({ phases: [{ title: 'D2', lines: [{ sku: 'A' }, { sku: 'B' }] }] }, 'phases', 0, ['phases[0].title'],
+ { phases: [{ title: 'D', lines: [{ sku: 'A' }, { sku: 'B' }] }] })
+ const line = rowForm({ phases: [{ lines: [undefined, { sku: 'B2' }] }] }, 'phases.0.lines', 1, ['phases.0.lines[1].sku'],
+ { phases: [{ lines: [undefined, { sku: 'B' }] }] })
+ const written = { phases: [{ title: 'D2', lines: [{ sku: 'A' }, { sku: 'B2' }] }] }
+ const json = { ...cloneDeep(written), others: [{ kind: 'new' }] }
+ const rows = [
+ { place: 'phases[0]', row: written.phases[0], initial: { title: 'D', lines: [{ sku: 'A' }, { sku: 'B' }] } },
+ { place: 'phases.0.lines[1]', row: written.phases[0].lines[1], initial: { sku: 'B' } },
+ ]
+
+ const read = settle(json, rows, written, { phase, line })
+
+ expect(json.phases).toEqual([{ title: 'D', lines: [{ sku: 'A' }, { sku: 'B' }] }])
+ expect(read.phases).toEqual(written.phases)
+ })
+})
+
describe('more than one form that holds the whole document', () => {
// A document's own form holds the whole document, and so does a `useForm` block's that selects no values of its own
// (`engine/Data.tsx`, `wholeDocument`): each holds a copy of what the other edits, as it started. Until 2026-10-27 the
diff --git a/src/core/engine/__tests__/rules.row-form-values.test.js b/src/core/engine/__tests__/rules.row-form-values.test.js
index 6150280f..14c3b0dc 100644
--- a/src/core/engine/__tests__/rules.row-form-values.test.js
+++ b/src/core/engine/__tests__/rules.row-form-values.test.js
@@ -40,15 +40,16 @@ const headers = [{ id: 'kind', label: 'Kind' }, { id: 'detail', label: 'Detail'
/**
* Mounts the meta on the data; the host records what `getFormData` reads, and what a submit sends, and can hand the
- * document other data.
+ * document other data. Asked to, it records the fields whose errors it is told of, each time.
*/
-function mount (meta, data) {
- const host = { reader: null, submitted: [] }
+function mount (meta, data, { hearErrors = false } = {}) {
+ const host = { reader: null, submitted: [], errors: [] }
const document = values => (
@@ -71,8 +72,9 @@ const type = (name, value) => {
fireEvent.blur(input)
}
-const click = name => act(async () => {
- fireEvent.click(screen.getByRole('button', { name }))
+/** Clicks the button `name`, the only one, or the `index`th of those of that name. */
+const click = (name, index) => act(async () => {
+ fireEvent.click(index === undefined ? screen.getByRole('button', { name }) : screen.getAllByRole('button', { name })[index])
await new Promise(resolve => setTimeout(resolve, 0))
})
@@ -890,3 +892,452 @@ describe.each([['`useForm` rows', true], ['rows sharing the document\'s form', f
expect(host.reader()).toEqual({ dataKind: { items: [{ kind: 'b', note: '' }, { kind: 'x', note: 'n' }] } })
})
})
+
+// An Add and a Remove rebuild the document's data from its form (`dataKindPush.ts`), and a `useForm` row whose data that
+// changes starts again from it (`WithForm`). Until 2026-10-27 the document's form held none of what such a row's cells
+// changed, so the row lost it: found in review, in a row whose expanded table gained or lost a line, and in a row whose
+// expanded content had an edit when a row was added. A removal lost what was typed in the rows after the removed one,
+// which move up a row. Rows that share the document's form kept theirs, and are driven the same way.
+const rowsOfBothKinds = [['have forms of their own', true], ['share the document\'s form', false]]
+
+/** Phases whose cells are a `title`, each with its lines below it, which a line's button removes and the add row adds. */
+const phasesWithLines = (phasesUseForm, linesUseForm) => ({
+ view: 'Col',
+ items: [{
+ view: 'Table',
+ name: 'dataKind.phases',
+ headers: [{ id: 'title', label: 'Phase' }],
+ itemsExpanded: true,
+ renderItemCells: {
+ view: 'Data', kind: 'phases', embedded: true, ...phasesUseForm && { useForm: true },
+ meta: { view: 'TableCells', items: [{ view: 'Input', name: 'title', type: 'text' }] },
+ },
+ renderItem: {
+ view: 'Col',
+ items: [{
+ view: 'Table',
+ name: 'dataKind.lineItems',
+ headers: [{ id: 'sku', label: 'SKU' }, { id: 'null', label: '' }],
+ renderItemCells: {
+ view: 'Data', kind: 'lineItems', embedded: true, ...linesUseForm && { useForm: true },
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'sku', type: 'text' },
+ { view: 'Button', children: 'Remove line', onClick: 'removeData' },
+ ],
+ },
+ },
+ renderExtraItem: {
+ view: 'Data', kind: 'lineItems', embedded: true, initialValues: {},
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'sku', type: 'text', label: 'New SKU' },
+ { view: 'Button', children: 'Add line', onClick: 'addData' },
+ ],
+ },
+ },
+ }],
+ },
+ }, { view: 'Button', children: 'Submit', onClick: 'submit' }],
+})
+
+const phases = { dataKind: { phases: [{ title: 'Design', dataKind: { lineItems: [{ sku: 'D-1' }, { sku: 'D-2' }] } }] } }
+
+describe.each(rowsOfBothKinds)('a table of lines below each row of a table whose cells %s', (_, useForm) => {
+ const meta = phasesWithLines(useForm, false)
+
+ it('keeps the title typed in a row as a line is removed from its table', async () => {
+ const { host } = mount(meta, phases)
+ const edited = { dataKind: { phases: [{ title: 'Design 2', dataKind: { lineItems: [{ sku: 'D-2' }] } }] } }
+
+ type('dataKind.phases[0].title', 'Design 2')
+ await click('Remove line', 0)
+
+ expect(field('dataKind.phases[0].title')).toHaveValue('Design 2')
+ expect(host.reader()).toEqual(edited)
+ await click('Submit')
+ expect(host.submitted).toEqual([edited])
+ })
+
+ it('keeps the title typed in a row as a line is added to its table', async () => {
+ const { host } = mount(meta, phases)
+ const edited = { dataKind: { phases: [{ title: 'Design 2', dataKind: { lineItems: [{ sku: 'D-1' }, { sku: 'D-2' }, { sku: 'D-3' }] } }] } }
+
+ type('dataKind.phases[0].title', 'Design 2')
+ type('dataKind.phases.0.dataKind.lineItems[2].sku', 'D-3')
+ await click('Add line')
+
+ expect(field('dataKind.phases[0].title')).toHaveValue('Design 2')
+ expect(host.reader()).toEqual(edited)
+ await click('Submit')
+ expect(host.submitted).toEqual([edited])
+ })
+})
+
+describe('a table of lines with forms of their own below each row of a table whose cells have forms of their own', () => {
+ it('keeps what was typed in the row, and in the line after a removed one, which moves up', async () => {
+ const { host } = mount(phasesWithLines(true, true), phases)
+ const edited = { dataKind: { phases: [{ title: 'Design 2', dataKind: { lineItems: [{ sku: 'D-2 edited' }] } }] } }
+
+ type('dataKind.phases[0].title', 'Design 2')
+ type('dataKind.phases.0.dataKind.lineItems[1].sku', 'D-2 edited')
+ await click('Remove line', 0)
+
+ expect(field('dataKind.phases[0].title')).toHaveValue('Design 2')
+ expect(field('dataKind.phases.0.dataKind.lineItems[0].sku')).toHaveValue('D-2 edited')
+ expect(host.reader()).toEqual(edited)
+ await click('Submit')
+ expect(host.submitted).toEqual([edited])
+ })
+})
+
+describe.each(rowsOfBothKinds)('an Add and a Remove in a table whose cells %s', (_, useForm) => {
+ // Each row's cells are its `kind` and a Remove button, and its `note` shows below it, in the document's form.
+ const meta = {
+ view: 'Col',
+ items: [{
+ view: 'Table',
+ name: 'dataKind.items',
+ headers: [{ id: 'kind', label: 'Kind' }, { id: 'null', label: '' }],
+ itemsExpanded: true,
+ renderItemCells: {
+ view: 'Data', kind: 'items', embedded: true, ...useForm && { useForm: true },
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'kind', type: 'text' },
+ { view: 'Button', children: 'Remove', onClick: 'removeData' },
+ ],
+ },
+ },
+ renderItem: { view: 'Col', items: [{ view: 'Input', name: 'note', type: 'text' }] },
+ renderExtraItem: {
+ view: 'Data', kind: 'items', embedded: true, initialValues: {},
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'kind', type: 'text', label: 'New kind' },
+ { view: 'Button', children: 'Add', onClick: 'addData' },
+ ],
+ },
+ },
+ }, { view: 'Button', children: 'Submit', onClick: 'submit' }],
+ }
+
+ it('keeps what was typed in a row and below it as a row is added', async () => {
+ const { host } = mount(meta, { dataKind: { items: [{ kind: 'a', note: 'old' }] } })
+ const edited = { dataKind: { items: [{ kind: 'typed', note: 'new' }, { kind: 'b' }] } }
+
+ type('dataKind.items[0].kind', 'typed')
+ type('dataKind.items[0].note', 'new')
+ type('dataKind.items[1].kind', 'b')
+ await click('Add')
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('typed')
+ expect(field('dataKind.items[0].note')).toHaveValue('new')
+ expect(host.reader()).toEqual(edited)
+ await click('Submit')
+ expect(host.submitted).toEqual([edited])
+ })
+
+ it('moves what was typed in the rows after a removed one up with them', async () => {
+ const { host } = mount(meta, { dataKind: { items: [{ kind: 'a' }, { kind: 'b' }, { kind: 'c' }] } })
+ const edited = { dataKind: { items: [{ kind: 'a-edited' }, { kind: 'c-edited' }] } }
+
+ type('dataKind.items[0].kind', 'a-edited')
+ type('dataKind.items[1].kind', 'b-edited')
+ type('dataKind.items[2].kind', 'c-edited')
+ await click('Remove', 1)
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('a-edited')
+ expect(field('dataKind.items[1].kind')).toHaveValue('c-edited')
+ expect(host.reader()).toEqual(edited)
+ await click('Submit')
+ expect(host.submitted).toEqual([edited])
+ })
+
+ // The second row moves up with its edit, and the third, which reads as the second did, takes its place. A form of
+ // its own handed a row that reads as the one it started from is not started again (`WithForm`): until its start
+ // moved to what the data holds of it (`settleWrittenRows`), it wrote its edit over that row too.
+ it('keeps an edit to the row it was made in, as a row that reads the same moves into its place', async () => {
+ const { host } = mount(meta, { dataKind: { items: [{ kind: 'x' }, { kind: 'a' }, { kind: 'a' }] } })
+ const edited = { dataKind: { items: [{ kind: 'a1' }, { kind: 'a' }] } }
+
+ type('dataKind.items[1].kind', 'a1')
+ await click('Remove', 0)
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('a1')
+ expect(host.reader()).toEqual(edited)
+ await click('Submit')
+ expect(host.submitted).toEqual([edited])
+ })
+
+ it('shows the rows a host hands it after an Add kept the edits in the data', async () => {
+ const { host, handOver } = mount(meta, { dataKind: { items: [{ kind: 'a', note: 'old' }] } })
+ type('dataKind.items[0].kind', 'typed')
+ type('dataKind.items[0].note', 'new')
+ type('dataKind.items[1].kind', 'b')
+ await click('Add')
+
+ handOver({ dataKind: { items: [{ kind: 'from the host', note: 'from the host' }] } })
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('from the host')
+ expect(field('dataKind.items[0].note')).toHaveValue('from the host')
+ expect(host.reader()).toEqual({ dataKind: { items: [{ kind: 'from the host', note: 'from the host' }] } })
+ })
+})
+
+// What an Add writes of a row is what the row's form changed (`changedInside`): a field its cells only show holds the
+// value the row started with, which, written, took back an edit the expanded content made of a field of the same name.
+describe.each(rowsOfBothKinds)('an Add in a table whose cells %s, one of them showing a field edited below them', (_, useForm) => {
+ it('keeps the edit below, and the one in the cells', async () => {
+ const meta = {
+ view: 'Col',
+ items: [{
+ view: 'Table',
+ name: 'dataKind.items',
+ headers: [{ id: 'kind', label: 'Kind' }, { id: 'note', label: 'Note' }],
+ itemsExpanded: true,
+ renderItemCells: {
+ view: 'Data', kind: 'items', embedded: true, ...useForm && { useForm: true },
+ meta: {
+ view: 'TableCells',
+ items: [{ view: 'Input', name: 'kind', type: 'text' }, { view: 'Input', name: 'note', type: 'text', readonly: true }],
+ },
+ },
+ renderItem: { view: 'Col', items: [{ view: 'Input', name: 'note', type: 'text' }] },
+ renderExtraItem: {
+ view: 'Data', kind: 'items', embedded: true, initialValues: {},
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'kind', type: 'text', label: 'New kind' },
+ { view: 'Button', children: 'Add', onClick: 'addData' },
+ ],
+ },
+ },
+ }, { view: 'Button', children: 'Submit', onClick: 'submit' }],
+ }
+ const { host } = mount(meta, { dataKind: { items: [{ kind: 'a', note: 'old' }] } })
+ const expandedNote = () => Array.from(document.querySelectorAll('[name="dataKind.items[0].note"]')).pop()
+ const edited = { dataKind: { items: [{ kind: 'typed', note: 'new' }, { kind: 'b' }] } }
+
+ type('dataKind.items[0].kind', 'typed')
+ fireEvent.focus(expandedNote())
+ fireEvent.change(expandedNote(), { target: { value: 'new' } })
+ fireEvent.blur(expandedNote())
+ type('dataKind.items[1].kind', 'b')
+ await click('Add')
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('typed')
+ expect(expandedNote()).toHaveValue('new')
+ expect(host.reader()).toEqual(edited)
+ await click('Submit')
+ expect(host.submitted).toEqual([edited])
+ })
+})
+
+// A `useForm` block whose `name` or `data` selects no values holds the whole document, as the document's own form does
+// (`wholeDocument`), and the merges take from each the fields it changed (`mergeDocuments`). An Add and a Remove in a
+// table beside it write the rows' edits into the document's form all the same, and move the start of each row they keep
+// written to what that form held of it once written.
+describe.each(rowsOfBothKinds)('an Add and a Remove beside a `useForm` block that holds the whole document, in a table whose cells %s', (_, useForm) => {
+ const meta = {
+ view: 'Col',
+ items: [
+ { view: 'Data', kind: 'block', useForm: true, meta: { view: 'Col', items: [{ view: 'Input', name: 'title', type: 'text' }] } },
+ {
+ view: 'Table',
+ name: 'dataKind.items',
+ headers: [{ id: 'kind', label: 'Kind' }, { id: 'null', label: '' }],
+ renderItemCells: {
+ view: 'Data', kind: 'items', embedded: true, ...useForm && { useForm: true },
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'kind', type: 'text' },
+ { view: 'Button', children: 'Remove', onClick: 'removeData' },
+ ],
+ },
+ },
+ renderExtraItem: {
+ view: 'Data', kind: 'items', embedded: true, initialValues: {},
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'kind', type: 'text', label: 'New kind' },
+ { view: 'Button', children: 'Add', onClick: 'addData' },
+ ],
+ },
+ },
+ },
+ { view: 'Button', children: 'Submit', onClick: 'submit' },
+ ],
+ }
+
+ it('keeps what was typed in a row as a row is added and another removed', async () => {
+ const { host } = mount(meta, { title: 'T', dataKind: { items: [{ kind: 'a' }, { kind: 'b' }] } })
+ const edited = { title: 'T', dataKind: { items: [{ kind: 'b-edited' }, { kind: 'c' }] } }
+
+ type('dataKind.items[1].kind', 'b-edited')
+ type('dataKind.items[2].kind', 'c')
+ await click('Add')
+ await click('Remove', 0)
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('b-edited')
+ expect(host.reader()).toEqual(edited)
+ await click('Submit')
+ expect(host.submitted).toEqual([edited])
+ })
+
+ it('keeps an edit to the row it was made in, as a row that reads the same moves into its place', async () => {
+ const { host } = mount(meta, { title: 'T', dataKind: { items: [{ kind: 'x' }, { kind: 'a' }, { kind: 'a' }] } })
+ const edited = { title: 'T', dataKind: { items: [{ kind: 'a1' }, { kind: 'a' }] } }
+
+ type('dataKind.items[1].kind', 'a1')
+ await click('Remove', 0)
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('a1')
+ expect(host.reader()).toEqual(edited)
+ await click('Submit')
+ expect(host.submitted).toEqual([edited])
+ })
+})
+
+// A row an Add or a Remove leaves where it was, and as it was, goes back into the data they rebuild as its form started
+// from it, so that its form keeps what it holds (`dataKindPush.ts`), as it did before the edits were written. Written
+// into that data with the other rows' edits, its edit started its form again from it. Measured while this was made: the
+// row was no longer dirty, and an error a field of it showed was gone from the field and from what the host was told,
+// until the field was touched again.
+describe('a `useForm` row an Add or a Remove leaves where it was and as it was', () => {
+ const meta = {
+ view: 'Table',
+ name: 'dataKind.items',
+ headers: [{ id: 'kind', label: 'Kind' }, { id: 'null', label: '' }],
+ renderItemCells: {
+ view: 'Data', kind: 'items', embedded: true, useForm: true,
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'kind', type: 'text', validate: 'required', required: true },
+ { view: 'Button', children: 'Remove', onClick: 'removeData' },
+ ],
+ },
+ },
+ renderExtraItem: {
+ view: 'Data', kind: 'items', embedded: true, initialValues: {},
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'kind', type: 'text', label: 'New kind' },
+ { view: 'Button', children: 'Add', onClick: 'addData' },
+ ],
+ },
+ },
+ }
+ /** The form of the row that holds the field `name`. */
+ const formOf = name => {
+ let holder
+ formsStorage.forEach(({ form }) => { if (form.getFieldState(name)) holder = form })
+ return holder
+ }
+
+ it('keeps its form through an Add: its edit, and the error a field of it shows and the host is told of', async () => {
+ const { host } = mount(meta, { dataKind: { items: [{ kind: 'a' }, { kind: 'x' }] } }, { hearErrors: true })
+ type('dataKind.items[0].kind', 'typed')
+ type('dataKind.items[1].kind', '')
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
+ expect(shown('dataKind.items[1].kind')).toBe('Required')
+ expect(host.errors[host.errors.length - 1]).toEqual(['dataKind.items[1].kind'])
+
+ type('dataKind.items[2].kind', 'b')
+ await click('Add')
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('typed')
+ expect(formOf('dataKind.items[0].kind').getState().pristine).toBe(false)
+ expect(shown('dataKind.items[1].kind')).toBe('Required')
+ expect(host.errors[host.errors.length - 1]).toEqual(['dataKind.items[1].kind'])
+ expect(field('dataKind.items[2].kind')).toHaveValue('b')
+ // The data holds the row as it started, so its form gives its edit, from what it started with.
+ expect(host.reader().dataKind.items[0]).toEqual({ kind: 'typed' })
+ type('dataKind.items[0].kind', 'a')
+ expect(host.reader().dataKind.items[0]).toEqual({ kind: 'a' })
+ })
+
+ it('keeps its form through a Remove of a row after it', async () => {
+ const { host } = mount(meta, { dataKind: { items: [{ kind: 'a' }, { kind: 'b' }, { kind: 'c' }] } })
+ type('dataKind.items[0].kind', 'a-edited')
+
+ await click('Remove', 2)
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('a-edited')
+ expect(formOf('dataKind.items[0].kind').getState().pristine).toBe(false)
+ expect(host.reader()).toEqual({ dataKind: { items: [{ kind: 'a-edited' }, { kind: 'b' }] } })
+ })
+})
+
+// A row whose form starts from the whole document, not from its row (`engine/Data.tsx`): a `null` row, for which the
+// `Data` view is handed the document, and a row whose cells read the document (`relativeData: false`). An Add or a Remove
+// changes the document, so such a form starts again from the data they rebuild, whatever they did to its row: its values
+// must be in that data. Found in review: put back, an outer row took a `null` line's value out of it, and a row of the
+// second kind went back as it started.
+describe('a `useForm` row whose form starts from the whole document', () => {
+ it('keeps what was typed in an empty line, in a row put back as it was, as a line is added below another row', async () => {
+ const { host } = mount(phasesWithLines(true, true), {
+ dataKind: {
+ phases: [
+ { title: 'Design', dataKind: { lineItems: [{ sku: 'D-1' }, null] } },
+ { title: 'Build', dataKind: { lineItems: [] } },
+ ],
+ },
+ })
+
+ type('dataKind.phases[0].title', 'Design 2')
+ type('dataKind.phases.0.dataKind.lineItems[1].sku', 'D-2')
+ type('dataKind.phases.1.dataKind.lineItems[0].sku', 'B-1')
+ await click('Add line', 1)
+
+ expect(field('dataKind.phases[0].title')).toHaveValue('Design 2')
+ expect(field('dataKind.phases.0.dataKind.lineItems[1].sku')).toHaveValue('D-2')
+ expect(host.reader()).toEqual({
+ dataKind: {
+ phases: [
+ { title: 'Design 2', dataKind: { lineItems: [{ sku: 'D-1' }, { sku: 'D-2' }] } },
+ { title: 'Build', dataKind: { lineItems: [{ sku: 'B-1' }] } },
+ ],
+ },
+ })
+ })
+
+ it('keeps what was typed in a row whose cells read the document, as a row is added', async () => {
+ const meta = {
+ view: 'Table',
+ name: 'dataKind.items',
+ headers: [{ id: 'kind', label: 'Kind' }, { id: 'null', label: '' }],
+ renderItemCells: {
+ view: 'Data', kind: 'items', embedded: true, useForm: true, relativeData: false,
+ meta: { view: 'TableCells', items: [{ view: 'Input', name: 'kind', type: 'text' }] },
+ },
+ renderExtraItem: {
+ view: 'Data', kind: 'items', embedded: true, initialValues: {},
+ meta: {
+ view: 'TableCells',
+ items: [
+ { view: 'Input', name: 'kind', type: 'text', label: 'New kind' },
+ { view: 'Button', children: 'Add', onClick: 'addData' },
+ ],
+ },
+ },
+ }
+ const { host } = mount(meta, { dataKind: { items: [{ kind: 'a' }, { kind: 'b' }] } })
+
+ type('dataKind.items[0].kind', 'typed')
+ type('dataKind.items[2].kind', 'c')
+ await click('Add')
+
+ expect(field('dataKind.items[0].kind')).toHaveValue('typed')
+ expect(host.reader()).toEqual({ dataKind: { items: [{ kind: 'typed' }, { kind: 'b' }, { kind: 'c' }] } })
+ })
+})
diff --git a/src/core/engine/dataKindPush.ts b/src/core/engine/dataKindPush.ts
index be4659a2..3e96d28d 100644
--- a/src/core/engine/dataKindPush.ts
+++ b/src/core/engine/dataKindPush.ts
@@ -2,6 +2,9 @@ import { get } from '../utils'
import { cloneDeep, set } from '../utils/object'
import { phraseMessage } from '../utils/phrases'
import type { PhraseMessage } from '../utils/phrases'
+import { dataFormsOf } from '../state/formRegistry'
+import { changedRowsFor, settleWrittenRows } from './formData'
+import type { ChangedRow, WrittenForm } from './formData'
/** A row, or a tree of rows, as final-form holds them. */
type Values = Record
@@ -10,6 +13,8 @@ type Values = Record
export type DataKindForm = {
getState: () => { values?: unknown }
reset: (values?: unknown) => void
+ change: (name: string, value: unknown) => void
+ batch: (changes: () => void) => void
mutators?: {
push?: (path: string, value: unknown) => void
remove?: (path: string, index: number) => unknown
@@ -20,6 +25,8 @@ export type DataKindForm = {
export type DataKindParent = {
props?: { instance?: { form?: DataKindForm } }
setState?: (update: (prev: { data?: object }) => object, callback?: () => void) => void
+ /** Its document tree, whose table rows with a form of their own have their edits kept (`keepRowEdits`) */
+ formTree?: object
}
/** A `Data` block's meta, as far as its rows are located. */
@@ -200,6 +207,26 @@ export function dataKindPathFor (meta: DataKindMeta, kind: string | undefined, f
return basePath ? `${basePath}.dataKind` : 'dataKind'
}
+/**
+ * Writes the rows whose own forms hold what the parent form does not into the parent form, as `changedRowsFor` gives
+ * them, before a row is added to it or removed from it (2026-10-27). An Add and a Remove rebuild the document's data
+ * from that form's values, which held none of what such a row's fields hold, and a row whose data that changed started
+ * again from it (`WithForm`), what was typed in its cells gone: a row whose expanded table gained or lost a line, or
+ * whose expanded content had an edit, and the rows after a removed one, which move up a row. Written while each row is
+ * at the place its form has it, so a removal moves an edit with its row. In one batch: the form's listeners hear once.
+ * Once the row is added or removed, `settleWrittenRows` puts back what that left as it was.
+ *
+ * @param {Object} parentForm - the form the row is added to or removed from
+ * @param {Map} forms - the forms of its document tree, `dataFormsOf` it; none, nothing is written
+ * @returns {Object} the rows written, and the form with its values once they were, for `settleWrittenRows`
+ */
+function keepRowEdits (parentForm: DataKindForm, forms: ReturnType): { rows: ChangedRow[], written: WrittenForm } {
+ const rows = changedRowsFor(parentForm, forms)
+ if (rows.length) parentForm.batch(() => rows.forEach(({ place, row }) => parentForm.change(place, row)))
+ // A cast, not a guard: the form's values are an object, which the caller has checked.
+ return { rows, written: { form: parentForm, values: parentForm.getState().values as Values } }
+}
+
/**
* Append one plain row object via the parent final-form array mutator, then sync `instance.state.data.json`
* from form values (same pattern as REMOVE_DATA). Do not also splice `data.json` manually — when that array
@@ -251,8 +278,11 @@ export function pushDataKindRow ({ parentUIRender, meta, kind, rowObject, fallba
console.warn(`ADD_DATA: dataKind not found at "${dataKindPath}" (kind="${kind}", relativePath="${rel}")`)
return false
}
+ const forms = dataFormsOf(parentUIRender.formTree)
+ const { rows, written } = keepRowEdits(parentForm, forms)
parentForm.mutators.push(arrayPath, rowObject)
const nextJson = cloneDeep(parentForm.getState().values)
+ settleWrittenRows(nextJson, rows, written, forms)
parentUIRender.setState(
(prev) => ({
data: {
@@ -298,7 +328,7 @@ export function pushDataKindRow ({ parentUIRender, meta, kind, rowObject, fallba
*/
/** What `removeDataKindRow` is given: see the parameters above. */
export type RemoveDataKindRowArgs = {
- parentUIRender: Required>
+ parentUIRender: Required> & Pick
parentForm?: DataKindForm | null
meta?: DataKindMeta
kind: string
@@ -312,8 +342,11 @@ export function removeDataKindRow ({ parentUIRender, parentForm, meta, kind, ind
console.warn('REMOVE_DATA: parent form or mutators.remove is not available')
return false
}
+ const forms = dataFormsOf(parentUIRender.formTree)
+ const { rows, written } = keepRowEdits(parentForm, forms)
parentForm.mutators.remove(arrayPath, Number(index))
const nextJson = compactDataKindArrays(cloneDeep(parentForm.getState().values))
+ settleWrittenRows(nextJson, rows, written, forms)
parentUIRender.setState((prev) => ({
data: {
...prev.data,
diff --git a/src/core/engine/formData.ts b/src/core/engine/formData.ts
index 12150c0b..cdd6fbdf 100644
--- a/src/core/engine/formData.ts
+++ b/src/core/engine/formData.ts
@@ -7,7 +7,7 @@
*/
import { getIn, setIn } from 'final-form'
import type { FormApi, FormState } from 'final-form'
-import { get, merge, isEqual, isObject, hasObjectValue, set } from '../utils/object'
+import { get, merge, isEqual, isEqualJSON, isObject, hasObjectValue, set } from '../utils/object'
import type { RegisteredForm } from '../state/formRegistry'
import { FIELD } from '../modules'
import { fieldNamesOf } from '../modules/form/batchedForm'
@@ -445,6 +445,130 @@ function mergeRows (at: string, arrays: unknown[][], owned: Owned[]): object[] {
return rows
}
+/**
+ * A row `changedRowsFor` gives: where it is, the row as it is to be written there, and, where the row may go back into
+ * the data as its form's `initialValues` hold it, that row (`initial`).
+ */
+export type ChangedRow = { place: string, row: unknown, initial?: object }
+
+/** The document's form the rows were written into, and its values once they were, as `settleWrittenRows` reads them. */
+export type WrittenForm = { form: { getState: unknown }, values: Values }
+
+/**
+ * THE ROWS WHOSE OWN FORMS HOLD WHAT `form` DOES NOT, AS `form` IS TO HOLD THEM (2026-10-27): for each table row with a
+ * form of its own, its place and the row as `form`'s values have it, with the values of the fields the row's form
+ * changed of its start (`changedInside`, `startOf`) written at their names (`writeField`), as `mergeForms` writes them,
+ * where that makes the row another. An Add and a Remove write them into the document's form before they add or remove
+ * a row there (`dataKindPush.ts`): the data they rebuild is that form's values.
+ *
+ * - Left out: a row its array in `form`'s values does not hold, as the live merge leaves it out (one just removed, or a
+ * hole in the array a row's own form holds), a row whose place there has another shape, which final-form throws
+ * for, and `form` itself, whose values hold its fields.
+ * - Shallowest first, each written over the rows before it, as `mergeForms` writes them: a row with a form of its own
+ * inside another such row comes after it, and is in the outer row as given.
+ * - `initial` is the row the row's form's `initialValues` hold, where putting it back leaves that form as it is: they
+ * hold just that row at its place, as a `useForm` row's do (`engine/Data.tsx`), `form` holds the row just so, and
+ * each row given inside it has an `initial` too. An Add or a Remove that leaves the row where it was, and as it was,
+ * puts it back into the data it rebuilds. A form that starts from the whole document, a `null` row's or one whose
+ * cells read the document (`relativeData: false`), starts again from that data whatever they did to its row: its
+ * values must be in it, and so in any row around it. Found in review: put back, an outer row took a `null` line's
+ * value away.
+ *
+ * @param {Object} form - the form the rows are to be written into, as far as its values are read
+ * @param {Map} forms - the forms of its document tree, `dataFormsOf`
+ * @returns {Array} the rows to write, each at its place, shallowest first
+ */
+export function changedRowsFor (form: { getState: () => { values?: unknown } }, forms: Forms): ChangedRow[] {
+ const { values } = form.getState()
+ const owned: Array<{ place: string, names: string[], values: Values, initialValues: unknown }> = []
+ forms.forEach(entry => {
+ // A cast, not a guard: what a form registers as its meta is its document's meta.
+ const meta = entry.meta as MetaNode | undefined
+ const place = rowPlaceOf(meta)
+ if (!place || entry.form.getState === form.getState) return
+ const rows = get(values, meta!.relativePath!) // not undefined: `rowPlaceOf` read both
+ if (!Array.isArray(rows) || !(Number(meta!.relativeIndex) in rows)) return
+ const state = entry.form.getState()
+ const names = changedInside(fieldsOf(entry.form), place, state.values, startOf(entry.form, state.initialValues))
+ if (names.length) owned.push({ place, names, values: state.values, initialValues: state.initialValues })
+ })
+ // A cast, not a guard: the values hold the rows' arrays, read above.
+ let next = values as object
+ const changed: Array<{ place: string, initial?: object }> = []
+ owned.sort((a, b) => depthOf(a.place) - depthOf(b.place)).forEach(({ place, names, values: own, initialValues }) => {
+ const row = get(next, place)
+ const from = dotted(place).length + 1
+ const written = names.reduce((target: unknown, name) => writeField(target, name.slice(from), getIn(own, name)), row)
+ if (isEqual(written, row)) return
+ try {
+ next = setIn(next, place, written)
+ } catch (anotherShape) {
+ return
+ }
+ const initial = get(initialValues, place)
+ const placed = isObject(initial) && isEqual(get(values, place), initial) &&
+ isEqualJSON(initialValues, setIn({}, place, initial))
+ changed.push({ place, initial: placed ? initial : undefined })
+ })
+ changed.forEach(outer => {
+ const inside = `${dotted(outer.place)}.`
+ if (changed.some(({ place, initial }) => initial === undefined && dotted(place).startsWith(inside))) {
+ outer.initial = undefined
+ }
+ })
+ return changed.map(({ place, initial }) => ({ place, row: get(next, place), initial }))
+}
+
+/**
+ * WHAT AN ADD OR A REMOVE LEAVES OF THE ROWS `changedRowsFor` GAVE, ONCE IT HAS ADDED OR REMOVED ITS ROW (2026-10-27).
+ *
+ * - A row the operation left where it was, and as it was, goes back into `json` as its form started from it
+ * (`initial`), shallowest first, so that an outer row takes the rows inside it along. Its form is handed what it
+ * started from, and keeps what it holds as it did before: its edits, its touched fields, and the errors it shows and
+ * tells the host. Written into the data, they started the form again from it, as one the operation moved or changed
+ * starts.
+ * - The form of every other row written measures its changes from what the document's form held of its fields once
+ * they were written (`restartWrittenRows`), as after a submit: the data holds its edits now. Read as they were
+ * written, before the row was added or removed: where each row still was. Most such forms start again from the data
+ * anyway, as their row moved or changed. One handed a moved row that reads as the one it started from does not, and
+ * wrote its edits over that row. A row put back keeps its start, as does each row inside it: the data holds none of
+ * their edits.
+ *
+ * @param {Object} json - the data the operation rebuilt from the document's form, which this changes
+ * @param {Array} rows - what `changedRowsFor` gave, shallowest first
+ * @param {Object} written - the document's form the rows were written into, and its values once they were
+ * @param {Map} forms - the forms of its document tree, `dataFormsOf`
+ */
+export function settleWrittenRows (json: unknown, rows: ChangedRow[], written: WrittenForm, forms: Forms): void {
+ // The places of the rows put back, each as the start of the places inside it.
+ const back: string[] = []
+ rows.forEach(({ place, row, initial }) => {
+ if (initial === undefined || !isEqualJSON(get(json, place), row)) return
+ set(json, place, cloneDeep(initial))
+ back.push(`${dotted(place)}.`)
+ })
+ const kept = new Set(rows.map(({ place }) => `${dotted(place)}.`)
+ .filter(place => !back.some(outer => place.startsWith(outer))))
+ // The rows kept written, and the forms that hold the document from its root, which `restartWrittenRows` reads: the
+ // one written as it was then.
+ const restarted = new Map