@jielga/tmdatagrid 2.0.0-beta.9 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1281 -768
  3. package/dist/index.js +4607 -3250
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/adding-rows.md +132 -0
  7. package/docs/anatomy.md +119 -0
  8. package/docs/card-view.md +108 -0
  9. package/docs/cell-selection.md +194 -0
  10. package/docs/column-layout.md +182 -0
  11. package/docs/column-menu.md +66 -0
  12. package/docs/columns.md +269 -0
  13. package/docs/components.md +311 -0
  14. package/docs/draft-store.md +242 -0
  15. package/docs/editing.md +303 -0
  16. package/docs/editors.md +250 -0
  17. package/docs/export.md +319 -0
  18. package/docs/filtering.md +362 -0
  19. package/docs/getting-started.md +123 -0
  20. package/docs/grouping.md +165 -0
  21. package/docs/loading-and-empty.md +92 -0
  22. package/docs/localization.md +79 -0
  23. package/docs/menu.md +143 -0
  24. package/docs/migrating-to-2.md +163 -0
  25. package/docs/pagination.md +144 -0
  26. package/docs/persistence.md +114 -0
  27. package/docs/portfolio-rebalancer.md +94 -0
  28. package/docs/query-builder.md +179 -0
  29. package/docs/quick-search.md +84 -0
  30. package/docs/row-details.md +115 -0
  31. package/docs/row-interaction.md +149 -0
  32. package/docs/row-pinning.md +132 -0
  33. package/docs/row-selection.md +136 -0
  34. package/docs/row-styling.md +133 -0
  35. package/docs/scrolling.md +112 -0
  36. package/docs/server-query.md +246 -0
  37. package/docs/server-side.md +206 -0
  38. package/docs/sorting.md +101 -0
  39. package/docs/styling.md +126 -0
  40. package/docs/summary-row.md +76 -0
  41. package/docs/testing.md +744 -0
  42. package/docs/toolbar.md +161 -0
  43. package/docs/use-tm-data-grid.md +361 -0
  44. package/package.json +22 -46
  45. package/skills/appearance/SKILL.md +72 -19
  46. package/skills/cell-selection/SKILL.md +49 -48
  47. package/skills/columns/SKILL.md +125 -70
  48. package/skills/columns/references/columns-api.md +59 -0
  49. package/skills/data/SKILL.md +112 -18
  50. package/skills/editing/SKILL.md +76 -42
  51. package/skills/editing/references/common-mistakes.md +77 -69
  52. package/skills/editing/references/editing-api.md +25 -20
  53. package/skills/editing/references/editors-and-validation.md +80 -18
  54. package/skills/filtering/SKILL.md +155 -41
  55. package/skills/getting-started/SKILL.md +116 -16
  56. package/skills/grouping/SKILL.md +31 -16
  57. package/skills/migrating-to-2/SKILL.md +244 -0
  58. package/skills/options/SKILL.md +24 -12
  59. package/skills/rows/SKILL.md +22 -18
  60. package/skills/rows/references/rows-api.md +10 -6
  61. package/skills/server-side/SKILL.md +170 -17
  62. package/skills/testing/SKILL.md +150 -32
  63. package/skills/testing-components/SKILL.md +230 -0
  64. package/skills/testing-editing/SKILL.md +240 -0
  65. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  66. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +54 -8
  67. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  68. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  69. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +5 -51
  70. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  71. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +12 -59
  72. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +164 -92
  73. package/src/components/TMDataGridExportPicker.module.css +77 -0
  74. package/src/components/TMDataGridExportPicker.tsx +234 -0
  75. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  76. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  77. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  78. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  79. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  80. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  81. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +5 -69
  82. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  83. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  84. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  85. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  86. package/src/components/TMDataGridMenu.tsx +357 -0
  87. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +11 -48
  88. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +69 -56
  89. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +240 -138
  90. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  91. package/src/components/TMDataGridToolbar.tsx +181 -0
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  96. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  97. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  98. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  99. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  100. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  101. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  102. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  103. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  104. package/src/components/filters/controlLayout.ts +32 -0
  105. package/src/components/filters/filterControlFor.ts +65 -0
  106. package/src/components/generatedColumns.tsx +187 -0
  107. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  108. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  109. package/src/components/useHideableColumns.ts +52 -0
  110. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  111. package/src/{tmdatagrid/core → core}/columnOptions.ts +60 -8
  112. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  113. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  114. package/src/core/controlledStateSync.ts +108 -0
  115. package/src/core/deletedRows.ts +34 -0
  116. package/src/core/dom.ts +74 -0
  117. package/src/{tmdatagrid/core → core}/editEngine.ts +1107 -460
  118. package/src/core/export.ts +704 -0
  119. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  120. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  121. package/src/core/filterSurface.ts +99 -0
  122. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  123. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  124. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  125. package/src/core/pageReset.ts +120 -0
  126. package/src/core/pagination.ts +81 -0
  127. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  128. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  129. package/src/{tmdatagrid/index.ts → index.ts} +69 -35
  130. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +428 -109
  131. package/src/useTMDataGridExport.ts +78 -0
  132. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  133. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  134. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  135. package/src/tmdatagrid/core/cellExport.ts +0 -320
  136. /package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +0 -0
  137. /package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +0 -0
  138. /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
  139. /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
  140. /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
  141. /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
  142. /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
  143. /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
  144. /package/src/{tmdatagrid/core → core}/capabilities.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/editorFocus.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  155. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  156. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  157. /package/src/{tmdatagrid/core → core}/useSettledTableState.ts +0 -0
@@ -5,7 +5,7 @@ import {
5
5
  type AnyFormApi,
6
6
  type StandardSchemaV1,
7
7
  } from "@tanstack/react-form";
8
- import { batch, Store } from "@tanstack/store";
8
+ import { Store } from "@tanstack/store";
9
9
  import type { Cell, Column, Row, RowData } from "@tanstack/react-table";
10
10
  import type { ComponentType } from "react";
11
11
  import type { TMDataGridRowData } from "../TMDataGridContext";
@@ -115,8 +115,10 @@ export type TMDataGridTableValidateArgs<
115
115
  /**
116
116
  * `editing.tableValidators` - rules that need the other rows: no duplicate
117
117
  * keys, no overlapping ranges, allocations summing to a total. Run at every
118
- * commit, after the row's own validators, and again per parked row during
119
- * `saveDrafts` - so a draft invalidated by a later edit blocks the save.
118
+ * commit, after the row's own validators, and again per committed row during
119
+ * `saveDrafts`, the only rules that run there - a committed row a later edit
120
+ * invalidated is reopened with the error, and the save reports it in
121
+ * `reopened`.
120
122
  *
121
123
  * Return nothing to pass, a message, or Form's `{ form, fields }` shape;
122
124
  * pathed issues land on the committing row's cells, pathless ones on the row.
@@ -194,20 +196,22 @@ export type TMDataGridEditState = {
194
196
  */
195
197
  active: { rowId: string; columnId: string | null } | null;
196
198
  /**
197
- * Rows with a live form, committed or not - every row the grid is holding
198
- * work for. A row is *open* (undecided form state) when it is in here and
199
- * not in {@link committedRowIds}.
199
+ * Every row the grid is holding work for: open rows, whose form is still
200
+ * undecided, and committed rows, whose values wait in the draft store. In
201
+ * the order the rows first entered; a reopen keeps a row's place. A row is
202
+ * *open* when it is in here and not in {@link committedRowIds}, or, for an
203
+ * entry row, not flagged `committed` in {@link newRows}.
200
204
  */
201
205
  openRowIds: ReadonlyArray<string>;
202
206
  rows: Record<string, TMDataGridEditRowProjection>;
203
207
  /**
204
- * The draft store's edit slice: existing rows whose form passed its submit
205
- * and is parked, waiting for `saveDrafts`. A subset of `openRowIds` - the
206
- * values stay in the row's form, this records which side of the line the
207
- * row is on. `begin` on one of these takes it back out, into form state.
208
+ * The draft store's edit slice: existing rows that passed their commit and
209
+ * wait for `saveDrafts`. A committed row is data, not a form: its values
210
+ * are in {@link committedValues}, and `begin` on one of these builds a
211
+ * fresh form from them and takes the row back out.
208
212
  *
209
- * Only `editing.draft` parks. Without it a commit goes straight to the
210
- * consumer and the form is dropped, so this stays empty.
213
+ * Only `editing.draft` commits into the store. Without it a commit goes
214
+ * straight to the consumer, so this stays empty.
211
215
  */
212
216
  committedRowIds: ReadonlyArray<string>;
213
217
  /**
@@ -223,12 +227,21 @@ export type TMDataGridEditState = {
223
227
  /**
224
228
  * Rows being created, not yet in `data`. `committed` is the draft store's
225
229
  * add slice: the entry row passed its submit and renders as a value row
226
- * until `begin` re-opens it. Without `editing.draft` a commit adds through
227
- * `onRowAdd` and the entry is dropped, so it never turns `true`.
230
+ * from {@link committedValues} until `begin` re-opens it. Without
231
+ * `editing.draft` a commit adds through `onRowAdd` and the entry is
232
+ * dropped, so it never turns `true`.
228
233
  */
229
234
  newRows: ReadonlyArray<{ tempId: string; committed: boolean }>;
230
235
  /** The draft store's delete slice: rows marked deleted, awaiting the save. */
231
236
  deletedRowIds: ReadonlyArray<string>;
237
+ /**
238
+ * `true` while `saveDrafts` is in flight - from the call until the
239
+ * consumer's callbacks (`onSaveDrafts`, or the per-row `onCommit` /
240
+ * `onRowAdd` / `onRowDelete` loop) have settled. Concurrent `saveDrafts`
241
+ * calls join the same run, so it flips once per run. It stays `false` for
242
+ * a save that finds nothing to send.
243
+ */
244
+ isSaving: boolean;
232
245
  };
233
246
 
234
247
  const EMPTY_EDIT_STATE: TMDataGridEditState = {
@@ -239,29 +252,54 @@ const EMPTY_EDIT_STATE: TMDataGridEditState = {
239
252
  committedValues: {},
240
253
  newRows: [],
241
254
  deletedRowIds: [],
255
+ isSaving: false,
242
256
  };
243
257
 
244
258
  /**
245
259
  * The rows still *open*: holding a live form nobody has decided yet.
246
260
  *
247
261
  * Narrower than {@link TMDataGridEditState.openRowIds}, which is every row
248
- * with a form, the parked ones included. A row qualifies here when it is not
249
- * parked in the draft store and there is something to lose - an entered row
250
- * always counts, an existing one only once a value has moved.
262
+ * the grid holds, the committed ones included. A row qualifies here when it
263
+ * is not committed in the draft store and there is something to lose - an
264
+ * entered row always counts, an existing one only once a value has moved.
251
265
  *
252
266
  * The order is the engine's: the order the forms were opened.
253
267
  */
254
268
  export function getOpenRowIds(
255
269
  state: TMDataGridEditState,
256
270
  ): ReadonlyArray<string> {
257
- return state.openRowIds.filter(
258
- (rowId) =>
259
- !state.committedRowIds.includes(rowId) &&
260
- !state.newRows.some(
261
- (newRow) => newRow.tempId === rowId && newRow.committed,
262
- ) &&
263
- (state.newRows.some((newRow) => newRow.tempId === rowId) ||
264
- (state.rows[rowId]?.dirtyFields.length ?? 0) > 0),
271
+ const committed = new Set(state.committedRowIds);
272
+ const entered = new Map(
273
+ state.newRows.map((newRow) => [newRow.tempId, newRow.committed]),
274
+ );
275
+ return state.openRowIds.filter((rowId) => {
276
+ if (committed.has(rowId)) return false;
277
+ const entryCommitted = entered.get(rowId);
278
+ if (entryCommitted !== undefined) return !entryCommitted;
279
+ return (state.rows[rowId]?.dirtyFields.length ?? 0) > 0;
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Whether the grid holds anything a navigation would lose: an open row with
285
+ * a moved value, an entry row, the draft store (edits, new rows, deletion
286
+ * marks), or a save still in flight.
287
+ *
288
+ * A pure selector over the edit state, so `useSelector(edit.store,
289
+ * hasPendingEdits)` re-renders only when the answer flips, and a navigation
290
+ * blocker can read `hasPendingEdits(edit.state)` at the moment it is asked.
291
+ * `isSaving` counts because the default per-row loop takes the deletion
292
+ * marks out of the store before its `onRowDelete` calls settle.
293
+ */
294
+ export function hasPendingEdits(state: TMDataGridEditState): boolean {
295
+ return (
296
+ state.isSaving ||
297
+ state.committedRowIds.length > 0 ||
298
+ state.deletedRowIds.length > 0 ||
299
+ state.newRows.length > 0 ||
300
+ state.openRowIds.some(
301
+ (rowId) => (state.rows[rowId]?.dirtyFields.length ?? 0) > 0,
302
+ )
265
303
  );
266
304
  }
267
305
 
@@ -348,15 +386,16 @@ export type TMDataGridEditValueMap = (
348
386
  * `meta.type` and `meta.options` stay outside this namespace on purpose: one
349
387
  * declaration of each feeds the cell editor and the filter panel alike.
350
388
  */
351
- export type TMDataGridColumnEditOptions = {
389
+ export type TMDataGridColumnEditOptions<
390
+ TData extends RowData = TMDataGridRowData,
391
+ > = {
352
392
  /**
353
393
  * Whether this column's cells take edits, once `editMode` is on. `false`
354
394
  * switches the column off outright; a predicate decides per row. Defaults
355
395
  * to editable for any column that maps to a field - see {@link field}.
396
+ * `row` is typed by the column helper the column was declared with.
356
397
  */
357
- enabled?:
358
- | boolean
359
- | ((row: Row<TMDataGridFeatures, TMDataGridRowData>) => boolean);
398
+ enabled?: boolean | ((row: Row<TMDataGridFeatures, TData>) => boolean);
360
399
  /**
361
400
  * The data path this column edits, when it is not the `accessorKey` - the
362
401
  * only way a column built on `accessorFn` becomes editable. Dot paths reach
@@ -407,10 +446,6 @@ export type TMDataGridSaveDraftsArgs<TData extends RowData> = {
407
446
  created: Array<TMDataGridRowAddArgs<TData>>;
408
447
  /** Ids marked deleted while the drafts accumulated. */
409
448
  deleted: Array<string>;
410
- /** @deprecated Renamed to {@link updated}. Removed in a later beta. */
411
- rows: Array<TMDataGridEditCommitArgs<TData>>;
412
- /** @deprecated Renamed to {@link created}. Removed in a later beta. */
413
- added: Array<TMDataGridRowAddArgs<TData>>;
414
449
  };
415
450
 
416
451
  /**
@@ -429,7 +464,7 @@ export type TMDataGridSaveOutcomes = boolean | Record<string, boolean>;
429
464
  * with nothing beyond the state itself - a failed edit keeps `data-draft`,
430
465
  * a failed deletion keeps `data-deleted` - so the display is the consumer's.
431
466
  */
432
- export type TMDataGridSaveDraftsResult = {
467
+ export type TMDataGridSaveDraftsResponse = {
433
468
  /** Keyed by `rowId`. */
434
469
  updated?: TMDataGridSaveOutcomes;
435
470
  /** Keyed by `tempId`. */
@@ -448,13 +483,6 @@ function isSaved(
448
483
  return outcomes[id] !== false;
449
484
  }
450
485
 
451
- /**
452
- * @deprecated Renamed to {@link TMDataGridSaveDraftsArgs} - the payload is
453
- * the draft store being saved, not a commit. Removed in a later beta.
454
- */
455
- export type TMDataGridEditCommitDraftsArgs<TData extends RowData> =
456
- TMDataGridSaveDraftsArgs<TData>;
457
-
458
486
  /** What the engine reads fresh on every call - see `createEditEngine`. */
459
487
  export type TMDataGridEditEngineContext = {
460
488
  table: TMDataGridTable<TMDataGridRowData>;
@@ -476,8 +504,8 @@ export type TMDataGridEditEngineContext = {
476
504
  args: TMDataGridSaveDraftsArgs<TMDataGridRowData>,
477
505
  ) =>
478
506
  | void
479
- | TMDataGridSaveDraftsResult
480
- | Promise<void | TMDataGridSaveDraftsResult>;
507
+ | TMDataGridSaveDraftsResponse
508
+ | Promise<void | TMDataGridSaveDraftsResponse>;
481
509
  /**
482
510
  * Seed values for `addRow`, under the values it is called with. A function
483
511
  * is called per added row.
@@ -537,6 +565,8 @@ export type TMDataGridAddRowsOptions = {
537
565
 
538
566
  /** What `edit.addRows` reports back. Every added row is in exactly one list. */
539
567
  export type TMDataGridAddRowsResult = {
568
+ /** `true` when every added row committed - `open` is empty. */
569
+ ok: boolean;
540
570
  /** Temp ids that committed - parked as drafts, or added outright. */
541
571
  committed: Array<string>;
542
572
  /**
@@ -546,6 +576,68 @@ export type TMDataGridAddRowsResult = {
546
576
  open: Array<string>;
547
577
  };
548
578
 
579
+ /**
580
+ * What `edit.commitAll` reports back. Every row that was open at the call is
581
+ * in exactly one list; ids are row ids, and temp ids for entry rows.
582
+ */
583
+ export type TMDataGridCommitAllResult = {
584
+ /** `true` when every open row committed - `open` is empty. */
585
+ ok: boolean;
586
+ /**
587
+ * Rows whose `commit` resolved `true` - into the draft store under
588
+ * `editing.draft`, out to the consumer without it, or closed with nothing
589
+ * to save.
590
+ */
591
+ committed: Array<string>;
592
+ /**
593
+ * Rows still open, carrying their errors: validation failed, or without
594
+ * `editing.draft` the consumer's `onCommit` / `onRowAdd` threw.
595
+ */
596
+ open: Array<string>;
597
+ };
598
+
599
+ /**
600
+ * What `edit.saveDrafts` reports back. Ids are row ids, and temp ids for new
601
+ * rows; edits, new rows and deletions are mixed in each list. Every id the
602
+ * save took from the draft store is in exactly one list. Rows open at the
603
+ * call are not part of the save and are in none.
604
+ */
605
+ export type TMDataGridSaveDraftsResult = {
606
+ /** `true` when nothing was kept or reopened. */
607
+ ok: boolean;
608
+ /** Left the draft store - the consumer accepted it. */
609
+ saved: Array<string>;
610
+ /**
611
+ * Still in the draft store, still committed, and sent again by the next
612
+ * save: an id `onSaveDrafts` reported as failed, every id it was sent
613
+ * when it threw, or, without `onSaveDrafts`, a deletion whose
614
+ * `onRowDelete` threw.
615
+ */
616
+ kept: Array<string>;
617
+ /**
618
+ * Out of the draft store and open again, carrying an error: a table rule
619
+ * now rejects the row, or, without `onSaveDrafts`, the row's own
620
+ * `onCommit` / `onRowAdd` threw.
621
+ */
622
+ reopened: Array<string>;
623
+ };
624
+
625
+ /**
626
+ * Splits ids by the `commit` each one got - the shape `addRows` and
627
+ * `commitAll` share. `results[index]` answers for `ids[index]`; order is kept.
628
+ */
629
+ function splitCommitted(
630
+ ids: ReadonlyArray<string>,
631
+ results: ReadonlyArray<boolean>,
632
+ ): { ok: boolean; committed: Array<string>; open: Array<string> } {
633
+ const committed: Array<string> = [];
634
+ const open: Array<string> = [];
635
+ ids.forEach((id, index) => {
636
+ (results[index] ? committed : open).push(id);
637
+ });
638
+ return { ok: open.length === 0, committed, open };
639
+ }
640
+
549
641
  /** One row of {@link TMDataGridEditApi.getRows}. */
550
642
  export type TMDataGridEditRowSnapshot<
551
643
  TData extends RowData = TMDataGridRowData,
@@ -563,9 +655,11 @@ export type TMDataGridEditRowSnapshot<
563
655
  /**
564
656
  * The engine plus its store - `api.edit`.
565
657
  *
566
- * "One row, one form": `getForm` hands out the same `FormApi` the inline
567
- * editors write through, so a consumer can render it in a drawer or a detail
568
- * panel and share values, dirty state and errors with the cells.
658
+ * "One row, one form" while a row is open: `getForm` hands out the same
659
+ * `FormApi` the inline editors write through, so a consumer can render it in
660
+ * a drawer or a detail panel and share values, dirty state and errors with
661
+ * the cells. A committed row has no form - it is data in the draft store -
662
+ * so `getForm` returns `undefined` for it until `begin` reopens it.
569
663
  */
570
664
  export type TMDataGridEditApi<
571
665
  TData extends RowData = TMDataGridRowData,
@@ -574,13 +668,17 @@ export type TMDataGridEditApi<
574
668
  store: Store<TMDataGridEditState>;
575
669
  /** Current snapshot, for reads outside React. */
576
670
  readonly state: TMDataGridEditState;
577
- /** rowId → live form. The source of truth for everything mid-edit. */
671
+ /**
672
+ * rowId → the open row's live form, the source of truth for everything
673
+ * mid-edit. `undefined` for a row that is not open, a committed row
674
+ * included; `begin` reopens one.
675
+ */
578
676
  getForm: (rowId: string) => TMDataGridRowEditForm | undefined;
579
677
  /**
580
- * The row as shown: its draft values where a form holds one - open or
581
- * parked in the draft store - else what `data` says. `undefined` when no
582
- * such row exists. A deletion mark does not change the answer; check
583
- * `state.deletedRowIds` for that.
678
+ * The row as shown: its draft values where the grid holds any - an open
679
+ * form's, or the committed values in the draft store - else what `data`
680
+ * says. `undefined` when no such row exists. A deletion mark does not
681
+ * change the answer; check `state.deletedRowIds` for that.
584
682
  */
585
683
  getRowValues: (rowId: string) => TData | undefined;
586
684
  /**
@@ -629,24 +727,33 @@ export type TMDataGridEditApi<
629
727
  * Submits every open row, as if each had been OK'd: a row that validates
630
728
  * commits (into the draft store with `editing.draft` on, straight to the
631
729
  * consumer without it), a row that fails stays open with its errors.
632
- * `true` when every row committed. Under `editing.draft` it sends nothing
633
- * to the consumer by itself - that is `saveDrafts`.
730
+ * Under `editing.draft` it sends nothing to the consumer by itself - that
731
+ * is `saveDrafts`.
732
+ *
733
+ * Resolves which rows went which way: `committed` and `open` hold every
734
+ * row that was open at the call, each in exactly one list, and `ok` is
735
+ * `true` when `open` is empty. See {@link TMDataGridCommitAllResult}.
634
736
  */
635
- commitAll: () => Promise<boolean>;
737
+ commitAll: () => Promise<TMDataGridCommitAllResult>;
636
738
  /**
637
739
  * Flushes the draft store: every committed edit, added row and deletion
638
740
  * mark reaches the consumer, through `onSaveDrafts` in one call when it is
639
741
  * set, or row by row through `onCommit` / `onRowAdd` / `onRowDelete`.
640
742
  *
641
743
  * Rows still open are left alone - they keep their form state and stay
642
- * open. `true` when everything landed; a rejected save keeps every draft.
643
- */
644
- saveDrafts: () => Promise<boolean>;
645
- /**
646
- * @deprecated Split into {@link commitAll} and {@link saveDrafts}, which is
647
- * exactly what this now does. Removed in a later beta.
744
+ * open, and are in no list of the result.
745
+ *
746
+ * Resolves what happened to each id it sent: `saved` left the draft store,
747
+ * `kept` is still in it, committed, for the next save - an id
748
+ * `onSaveDrafts` reported as failed, every id when it threw, or a
749
+ * deletion whose per-row `onRowDelete` threw - and
750
+ * `reopened` is open again with an error, because a table rule now
751
+ * rejects it or its per-row `onCommit` / `onRowAdd` threw. `ok` is `true`
752
+ * when nothing was kept or reopened, and an empty store resolves `ok` with
753
+ * empty lists. A call while a save is in flight joins it and resolves the
754
+ * same result. See {@link TMDataGridSaveDraftsResult}.
648
755
  */
649
- submitAll: () => Promise<boolean>;
756
+ saveDrafts: () => Promise<TMDataGridSaveDraftsResult>;
650
757
  /** Writes the type's empty value into a cell and commits it - Delete. */
651
758
  clearCell: (rowId: string, columnId: string) => Promise<boolean>;
652
759
  /**
@@ -691,15 +798,19 @@ export type TMDataGridEditApi<
691
798
  */
692
799
  addRow: (values?: Partial<TData>) => string;
693
800
  /**
694
- * Opens entry rows for a list of records at once - one state write for the
801
+ * Opens entry rows for a list of records at once - one publish for the
695
802
  * batch, where a loop over `addRow` is one per row. Each row is seeded over
696
803
  * `newRowDefaults` exactly as `addRow` does.
697
804
  *
698
- * `commit: true` submits each row as it lands, which is what an import
699
- * wants: rows that validate commit (parked in the draft store under
700
- * `editing.draft`, added through `onRowAdd` without it - once per row),
701
- * and rows that fail stay open in the entry block carrying their errors,
702
- * for the user to fix. The result says which went which way.
805
+ * `commit: true` submits the rows too, which is what an import wants: rows
806
+ * that validate commit, and rows that fail stay open in the entry block
807
+ * carrying their errors, for the user to fix. The result says which went
808
+ * which way - `committed` and `open` hold every added row, each in exactly
809
+ * one list, and `ok` is `true` when `open` is empty. Under
810
+ * `editing.draft` the rows validate together and land in the draft store
811
+ * in the same publish as the add - the grid renders once,
812
+ * whatever the count. Without it each valid row goes out through
813
+ * `onRowAdd`, one at a time and in order.
703
814
  */
704
815
  addRows: (
705
816
  rows: ReadonlyArray<Partial<TData>>,
@@ -707,11 +818,26 @@ export type TMDataGridEditApi<
707
818
  ) => Promise<TMDataGridAddRowsResult>;
708
819
  /**
709
820
  * Deletes a row: `onRowDelete` straight away, or under `editing.draft` a
710
- * toggle of the id in `deletedRowIds` - the row renders struck through
711
- * until `saveDrafts` reports it. On an uncommitted entry row it just
712
- * discards the entry.
821
+ * mark in `deletedRowIds` - the row renders struck through until
822
+ * `saveDrafts` reports it. Idempotent: deleting a marked row again leaves
823
+ * it marked, and {@link restoreRow} is the undo. On an entry row,
824
+ * committed or not, it just discards the entry; an id the grid does not
825
+ * know is a no-op.
713
826
  */
714
827
  deleteRow: (rowId: string) => void;
828
+ /**
829
+ * {@link deleteRow} for several rows in one call - one notification for
830
+ * the batch, for a bulk action over a selection. Because `deleteRow` is
831
+ * idempotent and ignores unknown ids, the list may be passed exactly as
832
+ * the selection stands - already-marked rows stay marked, duplicates and
833
+ * stale ids do nothing.
834
+ */
835
+ deleteRows: (rowIds: ReadonlyArray<string>) => void;
836
+ /**
837
+ * Removes a row's deletion mark - the lane's Restore. A no-op on a row
838
+ * that is not marked, and outside `editing.draft`, where no marks exist.
839
+ */
840
+ restoreRow: (rowId: string) => void;
715
841
  /** Whether delete chrome makes sense - the lane's trash gate. */
716
842
  canDeleteRows: () => boolean;
717
843
  };
@@ -835,22 +961,36 @@ async function runFieldValidator(
835
961
  * form keeps its values, meta and errors; scroll back and the editor
836
962
  * re-mounts over the same form.
837
963
  */
964
+ /**
965
+ * The engine as the hook holds it: the public `edit` API plus the verbs the
966
+ * grid calls on its own behalf and does not document.
967
+ */
968
+ export type TMDataGridEditEngine = TMDataGridEditApi & {
969
+ /** See the implementation. `rowsById` is the core row model's. */
970
+ forgetMissingRows: (rowsById: Record<string, unknown>) => void;
971
+ /**
972
+ * Whether the row carries a deletion mark - the set behind
973
+ * `state.deletedRowIds`, for a check per row from a predicate the table
974
+ * calls per row.
975
+ */
976
+ isRowDeleted: (rowId: string) => boolean;
977
+ };
978
+
838
979
  export function createEditEngine(
839
980
  getContext: () => TMDataGridEditEngineContext,
840
- ): TMDataGridEditApi {
981
+ ): TMDataGridEditEngine {
841
982
  const store = new Store<TMDataGridEditState>(EMPTY_EDIT_STATE);
842
983
 
984
+ /**
985
+ * One open row's editor state. Only an open row has one: a row that
986
+ * commits into the draft store hands its values to `committed` and its
987
+ * form is dropped, so nothing in here describes a committed row.
988
+ */
843
989
  type FormEntry = {
844
990
  form: TMDataGridRowEditForm;
845
991
  original: TMDataGridRowData;
846
992
  /** An entry-block row - a form with no backing row yet. */
847
993
  isNew: boolean;
848
- /**
849
- * In the draft store: this row's form passed its submit and is parked,
850
- * waiting for `saveDrafts`. Mirrored into the state's `committedRowIds`
851
- * (existing rows) or `newRows[].committed` (entry rows).
852
- */
853
- committed: boolean;
854
994
  /** Set by the wrapped onSubmit when the consumer's commit resolved. */
855
995
  lastSubmitOk: boolean;
856
996
  /**
@@ -862,31 +1002,35 @@ export function createEditEngine(
862
1002
  submitErrors: Array<{ field: string; value: unknown; message: string }>;
863
1003
  /**
864
1004
  * The park: this submit validates and puts the row in the draft store
865
- * instead of calling the consumer. Set by `commit` per attempt - `true`
866
- * only while `editing.draft` is on and outside `saveDrafts`, which is
867
- * what closes the per-row escape hatches (the lane's ✓,
868
- * Delete-to-clear) at the engine.
1005
+ * instead of calling the consumer. Set by `commit` per attempt, from
1006
+ * `editing.draft`.
869
1007
  */
870
1008
  parkOnly: boolean;
871
1009
  /** A commit already running - Enter and blur race on the same edit. */
872
1010
  pendingCommit: Promise<boolean> | null;
873
1011
  unsubscribe: () => void;
874
- unmount: () => void;
875
1012
  };
876
1013
  const forms = new Map<string, FormEntry>();
877
- let newRowCounter = 0;
878
- /** Lets `commit` tell a `saveDrafts` flush apart from a lone commit. */
879
- let savingDrafts = false;
880
1014
 
881
1015
  /**
882
- * While `saveDrafts` runs with an `onSaveDrafts`, each row's wrapped
883
- * onSubmit contributes its args here instead of calling `onEditCommit` -
884
- * validation stays per row (Form's), the consumer call becomes one.
1016
+ * One committed row in the draft store. A committed row is plain data:
1017
+ * it passed its validators, the user left it, and nothing can change its
1018
+ * values until a reopen builds a form back from this. Which is what keeps
1019
+ * an import of ten thousand rows to a few megabytes - a live `FormApi` per
1020
+ * row is some eight kilobytes each - and what makes the store serializable.
885
1021
  */
886
- let draftCollector: Array<TMDataGridEditCommitArgs<TMDataGridRowData>> | null =
887
- null;
888
- let draftAddCollector: Array<TMDataGridRowAddArgs<TMDataGridRowData>> | null =
889
- null;
1022
+ type Committed = {
1023
+ /** The data row, or the entry row's seed - what `changes` diffs against. */
1024
+ original: TMDataGridRowData;
1025
+ /** The committed values; `committedValues[rowId]` holds this same object. */
1026
+ values: TMDataGridRowData;
1027
+ /** `diff(original, values)` as it stood at commit time. */
1028
+ dirtyFields: Array<string>;
1029
+ isNew: boolean;
1030
+ };
1031
+ const committed = new Map<string, Committed>();
1032
+ let newRowCounter = 0;
1033
+ const NEW_ROW_ID_PREFIX = "__new__";
890
1034
 
891
1035
  /**
892
1036
  * The column's half of the rule, with no row in hand: it is a consumer
@@ -946,49 +1090,62 @@ export function createEditEngine(
946
1090
  return Object.keys(fields).length > 0 ? fields : undefined;
947
1091
  };
948
1092
 
1093
+ /**
1094
+ * The draft the grid is holding for a row: an open form's live values, or
1095
+ * a committed row's snapshot. `undefined` for a row it holds nothing for.
1096
+ */
1097
+ const draftValues = (rowId: string): TMDataGridRowData | undefined => {
1098
+ const entry = forms.get(rowId);
1099
+ if (entry !== undefined) {
1100
+ return entry.form.state.values as TMDataGridRowData;
1101
+ }
1102
+ return committed.get(rowId)?.values;
1103
+ };
1104
+
949
1105
  /**
950
1106
  * The collection as a table validator sees it: every data row overlaid
951
- * with its draft where a form holds one, entry rows appended, and rows
952
- * marked deleted removed. The committing row contributes the values being
953
- * submitted, not what `data` still says. Built from the core row model, so
954
- * it is unfiltered and never contains group rows.
1107
+ * with its draft where the grid holds one, entry rows appended, and rows
1108
+ * marked deleted removed. The committing row, when there is one,
1109
+ * contributes the values being submitted rather than what `data` or the
1110
+ * draft store still says. Built from the core row model, so it is
1111
+ * unfiltered and never contains group rows.
955
1112
  */
956
- const mergedRows = (
957
- rowId: string,
958
- value: TMDataGridRowData,
959
- isNew: boolean,
960
- ): Array<{ rowId: string; value: TMDataGridRowData }> => {
961
- const deleted = new Set(store.state.deletedRowIds);
1113
+ const mergedRows = (committing?: {
1114
+ rowId: string;
1115
+ value: TMDataGridRowData;
1116
+ isNew: boolean;
1117
+ }): Array<{ rowId: string; value: TMDataGridRowData }> => {
1118
+ const deleted = working.deletedRowIds;
962
1119
  const rows: Array<{ rowId: string; value: TMDataGridRowData }> = [];
963
1120
  const model = getContext().table.getCoreRowModel();
964
1121
  for (const row of model.flatRows) {
965
1122
  if (deleted.has(row.id)) continue;
966
- if (row.id === rowId) {
967
- rows.push({ rowId, value });
1123
+ if (row.id === committing?.rowId) {
1124
+ rows.push({ rowId: row.id, value: committing.value });
968
1125
  continue;
969
1126
  }
970
- const held = forms.get(row.id);
971
1127
  rows.push({
972
1128
  rowId: row.id,
973
- value:
974
- held === undefined
975
- ? (row.original as TMDataGridRowData)
976
- : (held.form.state.values as TMDataGridRowData),
1129
+ value: draftValues(row.id) ?? (row.original as TMDataGridRowData),
977
1130
  });
978
1131
  }
979
1132
  // Entry rows the table does not hold: the open ones, and the committed
980
1133
  // ones under `newRowsSticky`. A committed row in flow is in `data`
981
1134
  // already, so it was listed above.
982
- for (const newRow of store.state.newRows) {
983
- if (newRow.tempId === rowId || newRow.tempId in model.rowsById) continue;
984
- const held = forms.get(newRow.tempId);
985
- if (held === undefined || deleted.has(newRow.tempId)) continue;
986
- rows.push({
987
- rowId: newRow.tempId,
988
- value: held.form.state.values as TMDataGridRowData,
989
- });
1135
+ for (const tempId of working.newRows.keys()) {
1136
+ if (tempId === committing?.rowId || tempId in model.rowsById) continue;
1137
+ if (deleted.has(tempId)) continue;
1138
+ const values = draftValues(tempId);
1139
+ if (values === undefined) continue;
1140
+ rows.push({ rowId: tempId, value: values });
1141
+ }
1142
+ if (
1143
+ committing !== undefined &&
1144
+ committing.isNew &&
1145
+ !(committing.rowId in model.rowsById)
1146
+ ) {
1147
+ rows.push({ rowId: committing.rowId, value: committing.value });
990
1148
  }
991
- if (isNew && !(rowId in model.rowsById)) rows.push({ rowId, value });
992
1149
  return rows;
993
1150
  };
994
1151
 
@@ -996,11 +1153,17 @@ export function createEditEngine(
996
1153
  * Runs `editing.tableValidators` for one commit. `onSubmit` first; its
997
1154
  * failure stands and `onSubmitAsync` is not consulted, mirroring how the
998
1155
  * column triggers short-circuit.
1156
+ *
1157
+ * `rows` is the collection to judge against, for the caller that has one
1158
+ * already: a Save pass runs the rules over rows whose values are frozen,
1159
+ * so building the collection once there turns an O(N²) pass into an O(N)
1160
+ * one. Left out, this builds the view for the committing row itself.
999
1161
  */
1000
1162
  const runTableValidators = async (
1001
1163
  value: TMDataGridRowData,
1002
1164
  rowId: string,
1003
1165
  isNew: boolean,
1166
+ rows?: ReadonlyArray<{ rowId: string; value: TMDataGridRowData }>,
1004
1167
  ): Promise<unknown> => {
1005
1168
  const validators = getContext().tableValidators;
1006
1169
  if (
@@ -1013,7 +1176,7 @@ export function createEditEngine(
1013
1176
  value,
1014
1177
  rowId,
1015
1178
  isNew,
1016
- rows: mergedRows(rowId, value, isNew),
1179
+ rows: rows ?? mergedRows({ rowId, value, isNew }),
1017
1180
  };
1018
1181
  const sync = validators.onSubmit?.(args);
1019
1182
  if (isValidationError(sync)) return sync;
@@ -1094,15 +1257,16 @@ export function createEditEngine(
1094
1257
  };
1095
1258
  };
1096
1259
 
1260
+ /** What moved between a row as it was and a row as drafted. */
1097
1261
  const diff = (
1098
- entry: FormEntry,
1262
+ original: TMDataGridRowData,
1099
1263
  values: TMDataGridRowData,
1100
1264
  ): Array<TMDataGridEditChange> => {
1101
1265
  const changes: Array<TMDataGridEditChange> = [];
1102
1266
  for (const column of editableColumns()) {
1103
1267
  const field = getEditFieldName(column);
1104
1268
  if (field === null) continue;
1105
- const previous: unknown = getBy(entry.original, field);
1269
+ const previous: unknown = getBy(original, field);
1106
1270
  const next: unknown = getBy(values, field);
1107
1271
  if (!sameValue(previous, next)) {
1108
1272
  changes.push({ columnId: column.id, field, previous, next });
@@ -1134,7 +1298,7 @@ export function createEditEngine(
1134
1298
  ...kept.map(({ field, message }) => ({ field, message })),
1135
1299
  ];
1136
1300
  return {
1137
- dirtyFields: diff(entry, values).map((change) => change.field),
1301
+ dirtyFields: diff(entry.original, values).map((change) => change.field),
1138
1302
  errorFields: errorMessages.map((error) => error.field),
1139
1303
  errorMessages,
1140
1304
  hasRowError: hasAnyError(state.errors),
@@ -1143,143 +1307,314 @@ export function createEditEngine(
1143
1307
  };
1144
1308
  };
1145
1309
 
1310
+ /**
1311
+ * The same projection for a committed row, built from its snapshot. There
1312
+ * is no form to read and nothing left to decide: the values passed every
1313
+ * rule on the way in, so the error slots are empty and stay empty until a
1314
+ * reopen puts the row back into form state.
1315
+ */
1316
+ const projectCommitted = (
1317
+ snapshot: Committed,
1318
+ ): TMDataGridEditRowProjection => ({
1319
+ dirtyFields: snapshot.dirtyFields,
1320
+ errorFields: [],
1321
+ errorMessages: [],
1322
+ hasRowError: false,
1323
+ isSubmitting: false,
1324
+ values: snapshot.values,
1325
+ });
1326
+
1327
+ /**
1328
+ * The engine's own copy of the state, in maps and sets, so a write is O(1)
1329
+ * where the published shape would copy an array or a record. Every write
1330
+ * lands here; `publish` materializes it into `store`, rebuilding only the
1331
+ * slices that changed - a keystroke in an open editor leaves
1332
+ * `committedValues` and `newRows` identity-stable, which is what keeps the
1333
+ * table from rebuilding its row model under a caret.
1334
+ *
1335
+ * Holding the publish is what makes a batch verb scale. `addRows`,
1336
+ * `commitAll`, `saveDrafts` and `cancelAll` each touch every row they
1337
+ * hold, and an immutable write per row was quadratic: each one copied
1338
+ * `rows` and `committedValues`, rebuilt `openRowIds` and woke every
1339
+ * subscriber, so an import of ten thousand rows spent minutes copying and
1340
+ * re-rendering. Held, a run's writes stay O(1) each and the grid renders
1341
+ * once when it ends.
1342
+ */
1343
+ const working = {
1344
+ active: null as TMDataGridEditState["active"],
1345
+ /**
1346
+ * Every row the grid holds work for, open or committed, in the order the
1347
+ * rows first entered. Its own registry rather than `forms.keys()` plus
1348
+ * the committed ids: rows commit in whatever order their async
1349
+ * validators resolve, and the published order is the order the user
1350
+ * opened them in. A re-add is a no-op on a Set, so a reopen keeps a
1351
+ * row's place.
1352
+ */
1353
+ openRowIds: new Set<string>(),
1354
+ rows: new Map<string, TMDataGridEditRowProjection>(),
1355
+ committedRowIds: new Set<string>(),
1356
+ committedValues: new Map<string, TMDataGridRowData>(),
1357
+ /** `tempId` to its committed flag, in the order the rows were opened. */
1358
+ newRows: new Map<string, boolean>(),
1359
+ deletedRowIds: new Set<string>(),
1360
+ isSaving: false,
1361
+ };
1362
+ /** The slices the next publish has to rebuild. */
1363
+ const dirty = new Set<keyof TMDataGridEditState>();
1364
+ /** Forms whose projection is stale - projected once each, at publish. */
1365
+ const staleRows = new Set<string>();
1366
+ /** Above zero, `publish` waits for the run holding it to end. */
1367
+ let holdDepth = 0;
1368
+
1369
+ // Rows the engine took out of the table, and rows that only lost their
1370
+ // selection - see flushRowState.
1371
+ const pendingLeft = new Set<string>();
1372
+ const pendingUnselect = new Set<string>();
1373
+
1374
+ /**
1375
+ * Drops rows the engine took out of the table from the table state keyed
1376
+ * by row id: `rowSelection`, `expanded` and `rowPinning`. A row that was
1377
+ * only marked for deletion loses its selection alone - it is still there,
1378
+ * pinned or open as it was.
1379
+ *
1380
+ * TanStack never prunes those maps itself: `toggleAllRowsSelected(false)`
1381
+ * unticks only rows still in the model, `getIsSomeRowsSelected` counts
1382
+ * every key, and `expanded` is persisted with whatever ids it holds. An id
1383
+ * left behind keeps the header box indeterminate, flips "select all" back
1384
+ * to selecting, and shows a consumer reading the keys a row that is gone.
1385
+ * Two kinds of row leave through the engine: an entry row, discarded or
1386
+ * saved - its temp id never comes back - and a marked row whose deletion
1387
+ * the consumer has acted on. Run from `publish`, so a bulk delete or a
1388
+ * save prunes in one update per slice.
1389
+ */
1390
+ const flushRowState = () => {
1391
+ if (pendingLeft.size === 0 && pendingUnselect.size === 0) return;
1392
+ const left = [...pendingLeft];
1393
+ const unselect = [...left, ...pendingUnselect];
1394
+ pendingLeft.clear();
1395
+ pendingUnselect.clear();
1396
+ const table = getContext().table;
1397
+ const { rowSelection, expanded, rowPinning } = table.store.state;
1398
+ const has = (map: object, rowId: string) =>
1399
+ Object.prototype.hasOwnProperty.call(map, rowId);
1400
+ if (unselect.some((rowId) => has(rowSelection, rowId))) {
1401
+ table.setRowSelection((old) => {
1402
+ const next = { ...old };
1403
+ for (const rowId of unselect) delete next[rowId];
1404
+ return next;
1405
+ });
1406
+ }
1407
+ if (left.length === 0) return;
1408
+ if (expanded !== true && left.some((rowId) => has(expanded, rowId))) {
1409
+ table.setExpanded((old) => {
1410
+ if (old === true) return old;
1411
+ const next = { ...old };
1412
+ for (const rowId of left) delete next[rowId];
1413
+ return next;
1414
+ });
1415
+ }
1416
+ const gone = new Set(left);
1417
+ if (
1418
+ rowPinning.top.some((rowId) => gone.has(rowId)) ||
1419
+ rowPinning.bottom.some((rowId) => gone.has(rowId))
1420
+ ) {
1421
+ table.setRowPinning((old) => ({
1422
+ top: old.top.filter((rowId) => !gone.has(rowId)),
1423
+ bottom: old.bottom.filter((rowId) => !gone.has(rowId)),
1424
+ }));
1425
+ }
1426
+ };
1427
+
1428
+ const publish = () => {
1429
+ if (holdDepth > 0) return;
1430
+ flushRowState();
1431
+ if (staleRows.size > 0) {
1432
+ for (const rowId of staleRows) {
1433
+ const entry = forms.get(rowId);
1434
+ if (entry !== undefined) working.rows.set(rowId, project(entry));
1435
+ // A row whose form is gone because it committed keeps the projection
1436
+ // `releaseForm` wrote from its snapshot; only a row that left the
1437
+ // grid loses it.
1438
+ else if (!committed.has(rowId)) working.rows.delete(rowId);
1439
+ }
1440
+ staleRows.clear();
1441
+ dirty.add("rows");
1442
+ }
1443
+ if (dirty.size === 0) return;
1444
+ const next = { ...store.state };
1445
+ if (dirty.has("active")) next.active = working.active;
1446
+ if (dirty.has("openRowIds")) next.openRowIds = [...working.openRowIds];
1447
+ if (dirty.has("rows")) next.rows = Object.fromEntries(working.rows);
1448
+ if (dirty.has("committedRowIds")) {
1449
+ next.committedRowIds = [...working.committedRowIds];
1450
+ }
1451
+ if (dirty.has("committedValues")) {
1452
+ next.committedValues = Object.fromEntries(working.committedValues);
1453
+ }
1454
+ if (dirty.has("newRows")) {
1455
+ next.newRows = [...working.newRows].map(([tempId, committed]) => ({
1456
+ tempId,
1457
+ committed,
1458
+ }));
1459
+ }
1460
+ if (dirty.has("deletedRowIds")) {
1461
+ next.deletedRowIds = [...working.deletedRowIds];
1462
+ }
1463
+ if (dirty.has("isSaving")) next.isSaving = working.isSaving;
1464
+ dirty.clear();
1465
+ store.setState(() => next);
1466
+ };
1467
+
1468
+ /** Marks slices changed and publishes - the ordinary single write. */
1469
+ const touch = (...slices: Array<keyof TMDataGridEditState>) => {
1470
+ for (const slice of slices) dirty.add(slice);
1471
+ publish();
1472
+ };
1473
+
1474
+ /** Runs `body` with the publish held: one render for the whole run. */
1475
+ const held = async <T,>(body: () => Promise<T>): Promise<T> => {
1476
+ holdDepth += 1;
1477
+ try {
1478
+ return await body();
1479
+ } finally {
1480
+ holdDepth -= 1;
1481
+ publish();
1482
+ }
1483
+ };
1484
+
1485
+ /** {@link held} for a synchronous run. */
1486
+ const heldSync = <T,>(body: () => T): T => {
1487
+ holdDepth += 1;
1488
+ try {
1489
+ return body();
1490
+ } finally {
1491
+ holdDepth -= 1;
1492
+ publish();
1493
+ }
1494
+ };
1495
+
1146
1496
  const publishRow = (rowId: string) => {
1147
- const entry = forms.get(rowId);
1148
- store.setState((prev) => {
1149
- const rows = { ...prev.rows };
1150
- if (entry === undefined) delete rows[rowId];
1151
- else rows[rowId] = project(entry);
1152
- return { ...prev, rows, openRowIds: [...forms.keys()] };
1153
- });
1497
+ staleRows.add(rowId);
1498
+ publish();
1154
1499
  };
1155
1500
 
1156
1501
  const setActive = (active: TMDataGridEditState["active"]) => {
1157
- store.setState((prev) => ({ ...prev, active }));
1502
+ if (working.active === active) return;
1503
+ working.active = active;
1504
+ touch("active");
1158
1505
  };
1159
1506
 
1160
1507
  /**
1161
- * A commit's snapshot into `committedValues`. Refreshed on every commit,
1162
- * not only the first - `setCellValue` on a parked row writes and commits
1163
- * again without the row ever leaving the draft store. A reopen leaves the
1164
- * snapshot alone: the table keeps showing the last decided values until
1165
- * the next decision.
1508
+ * Publishes a committed row's values from its snapshot. Refreshed on every
1509
+ * commit, not only the first - `setCellValue` on a committed row reopens
1510
+ * it, writes and commits it again, and the table has to see the second
1511
+ * value as well as the first. A reopen leaves the published values alone:
1512
+ * the table keeps showing the last decided ones until the next decision.
1513
+ * Marks the slice; the caller publishes.
1166
1514
  */
1167
- const snapshotCommitted = (
1168
- prev: TMDataGridEditState,
1169
- rowId: string,
1170
- ): TMDataGridEditState["committedValues"] => {
1171
- const values = forms.get(rowId)?.form.state.values as
1172
- | TMDataGridRowData
1173
- | undefined;
1174
- if (values === undefined || prev.committedValues[rowId] === values) {
1175
- return prev.committedValues;
1515
+ const snapshotCommitted = (rowId: string) => {
1516
+ const values = committed.get(rowId)?.values;
1517
+ if (values === undefined || working.committedValues.get(rowId) === values) {
1518
+ return;
1176
1519
  }
1177
- return { ...prev.committedValues, [rowId]: values };
1520
+ working.committedValues.set(rowId, values);
1521
+ dirty.add("committedValues");
1178
1522
  };
1179
1523
 
1180
- const setNewRowCommitted = (tempId: string, committed: boolean) => {
1181
- store.setState((prev) => {
1182
- const target = prev.newRows.find((newRow) => newRow.tempId === tempId);
1183
- if (target === undefined) return prev;
1184
- const committedValues = committed
1185
- ? snapshotCommitted(prev, tempId)
1186
- : prev.committedValues;
1187
- if (
1188
- target.committed === committed &&
1189
- committedValues === prev.committedValues
1190
- ) {
1191
- return prev;
1192
- }
1193
- return {
1194
- ...prev,
1195
- committedValues,
1196
- newRows:
1197
- target.committed === committed
1198
- ? prev.newRows
1199
- : prev.newRows.map((newRow) =>
1200
- newRow.tempId === tempId ? { ...newRow, committed } : newRow,
1201
- ),
1202
- };
1203
- });
1524
+ const setNewRowCommitted = (tempId: string, isCommitted: boolean) => {
1525
+ const current = working.newRows.get(tempId);
1526
+ if (current === undefined) return;
1527
+ if (isCommitted) snapshotCommitted(tempId);
1528
+ if (current !== isCommitted) {
1529
+ working.newRows.set(tempId, isCommitted);
1530
+ dirty.add("newRows");
1531
+ }
1532
+ publish();
1204
1533
  };
1205
1534
 
1206
1535
  /**
1207
1536
  * Moves an existing row across the line between form state and the draft
1208
- * store. The values stay in the row's form; this records which side the
1209
- * row is on, snapshots them for the table on the way in, and the entry's
1210
- * flag keeps the two in step.
1537
+ * store. The values are the snapshot's; this records which side the row is
1538
+ * on and publishes them for the table on the way in. On the way out it
1539
+ * leaves `committedValues` standing, so the row keeps the place the last
1540
+ * decision gave it while the next one is being typed.
1211
1541
  */
1212
- const setCommitted = (rowId: string, committed: boolean) => {
1213
- const entry = forms.get(rowId);
1214
- if (entry !== undefined) entry.committed = committed;
1215
- store.setState((prev) => {
1216
- const has = prev.committedRowIds.includes(rowId);
1217
- const committedValues = committed
1218
- ? snapshotCommitted(prev, rowId)
1219
- : prev.committedValues;
1220
- if (has === committed && committedValues === prev.committedValues) {
1221
- return prev;
1222
- }
1223
- return {
1224
- ...prev,
1225
- committedValues,
1226
- committedRowIds:
1227
- has === committed
1228
- ? prev.committedRowIds
1229
- : committed
1230
- ? [...prev.committedRowIds, rowId]
1231
- : prev.committedRowIds.filter((id) => id !== rowId),
1232
- };
1233
- });
1234
- };
1235
-
1236
- const withoutCommittedValues = (
1237
- prev: TMDataGridEditState,
1238
- rowId: string,
1239
- ): TMDataGridEditState["committedValues"] => {
1240
- if (!(rowId in prev.committedValues)) return prev.committedValues;
1241
- const { [rowId]: _dropped, ...rest } = prev.committedValues;
1242
- return rest;
1542
+ const setCommitted = (rowId: string, isCommitted: boolean) => {
1543
+ if (isCommitted) snapshotCommitted(rowId);
1544
+ if (working.committedRowIds.has(rowId) !== isCommitted) {
1545
+ if (isCommitted) working.committedRowIds.add(rowId);
1546
+ else working.committedRowIds.delete(rowId);
1547
+ dirty.add("committedRowIds");
1548
+ }
1549
+ publish();
1243
1550
  };
1244
1551
 
1245
- const drop = (rowId: string) => {
1552
+ /**
1553
+ * Drops the form of a row that has just committed, leaving every trace of
1554
+ * the row itself: it is in the draft store now, and its projection is the
1555
+ * snapshot's. The form is what goes - eight kilobytes a row that nothing
1556
+ * can write to any more.
1557
+ */
1558
+ const releaseForm = (rowId: string) => {
1246
1559
  const entry = forms.get(rowId);
1247
1560
  if (entry === undefined) return;
1248
1561
  entry.unsubscribe();
1249
- entry.unmount();
1250
1562
  forms.delete(rowId);
1251
- store.setState((prev) => {
1252
- const rows = { ...prev.rows };
1253
- delete rows[rowId];
1254
- return {
1255
- ...prev,
1256
- rows,
1257
- openRowIds: [...forms.keys()],
1258
- committedRowIds: prev.committedRowIds.filter((id) => id !== rowId),
1259
- committedValues: withoutCommittedValues(prev, rowId),
1260
- newRows: entry.isNew
1261
- ? prev.newRows.filter((newRow) => newRow.tempId !== rowId)
1262
- : prev.newRows,
1263
- active: prev.active?.rowId === rowId ? null : prev.active,
1264
- };
1563
+ staleRows.delete(rowId);
1564
+ const snapshot = committed.get(rowId);
1565
+ if (snapshot !== undefined) {
1566
+ working.rows.set(rowId, projectCommitted(snapshot));
1567
+ dirty.add("rows");
1568
+ }
1569
+ publish();
1570
+ };
1571
+
1572
+ /** The row leaves the grid: its form, its snapshot and every flag go. */
1573
+ const forget = (rowId: string) => {
1574
+ heldSync(() => {
1575
+ const isNew =
1576
+ forms.get(rowId)?.isNew ?? committed.get(rowId)?.isNew ?? false;
1577
+ releaseForm(rowId);
1578
+ committed.delete(rowId);
1579
+ if (working.openRowIds.delete(rowId)) dirty.add("openRowIds");
1580
+ if (working.rows.delete(rowId)) dirty.add("rows");
1581
+ if (working.committedRowIds.delete(rowId)) dirty.add("committedRowIds");
1582
+ if (working.committedValues.delete(rowId)) dirty.add("committedValues");
1583
+ if (isNew && working.newRows.delete(rowId)) dirty.add("newRows");
1584
+ // An entry row leaves the table for good: discarded, or saved and
1585
+ // coming back under the consumer's own id. Its selection, expansion
1586
+ // and pin go with it - see flushRowState.
1587
+ if (isNew) pendingLeft.add(rowId);
1588
+ if (working.active?.rowId === rowId) {
1589
+ working.active = null;
1590
+ dirty.add("active");
1591
+ }
1265
1592
  });
1266
1593
  };
1267
1594
 
1595
+ /**
1596
+ * Opens a row: a form over `original`, which is the row as it was when the
1597
+ * editing began and what a commit's `changes` are diffed against.
1598
+ *
1599
+ * `values` is what the form starts out holding when that is not `original`
1600
+ * - a reopen or a demotion, where the row already has committed values.
1601
+ * They are written before the mount validation runs, so a rule judging the
1602
+ * row on the way in sees what the user last decided, not the seed.
1603
+ */
1268
1604
  const createForm = (
1269
1605
  rowId: string,
1270
1606
  original: TMDataGridRowData,
1271
1607
  isNew: boolean,
1608
+ values?: TMDataGridRowData,
1272
1609
  ): FormEntry => {
1273
1610
  const entry: FormEntry = {
1274
1611
  original,
1275
1612
  isNew,
1276
- committed: false,
1277
1613
  lastSubmitOk: false,
1278
1614
  submitErrors: [],
1279
1615
  parkOnly: false,
1280
1616
  pendingCommit: null,
1281
1617
  unsubscribe: () => {},
1282
- unmount: () => {},
1283
1618
  form: new FormApi({
1284
1619
  defaultValues: original,
1285
1620
  // The consumer's vocabulary is Form's own; the cast erases the
@@ -1294,12 +1629,8 @@ export function createEditEngine(
1294
1629
  tempId: rowId,
1295
1630
  value: value as TMDataGridRowData,
1296
1631
  };
1297
- if (draftAddCollector !== null) {
1298
- draftAddCollector.push(addArgs);
1299
- entry.lastSubmitOk = true;
1300
- return;
1301
- }
1302
- // Parked: validated and held for Save all - no consumer call.
1632
+ // Into the draft store: validated and held for Save - no
1633
+ // consumer call, and `commit` takes it from here.
1303
1634
  if (entry.parkOnly) {
1304
1635
  entry.lastSubmitOk = true;
1305
1636
  return;
@@ -1315,7 +1646,7 @@ export function createEditEngine(
1315
1646
  }
1316
1647
  return;
1317
1648
  }
1318
- const changes = diff(entry, value as TMDataGridRowData);
1649
+ const changes = diff(entry.original, value as TMDataGridRowData);
1319
1650
  const args: TMDataGridEditCommitArgs<TMDataGridRowData> = {
1320
1651
  rowId,
1321
1652
  value: value as TMDataGridRowData,
@@ -1323,12 +1654,8 @@ export function createEditEngine(
1323
1654
  changes,
1324
1655
  source: getContext().editMode,
1325
1656
  };
1326
- if (draftCollector !== null) {
1327
- draftCollector.push(args);
1328
- entry.lastSubmitOk = true;
1329
- return;
1330
- }
1331
- // Parked: validated and held for Save all - no consumer call.
1657
+ // Into the draft store: validated and held for Save - no consumer
1658
+ // call, and `commit` takes it from here.
1332
1659
  if (entry.parkOnly) {
1333
1660
  entry.lastSubmitOk = true;
1334
1661
  return;
@@ -1346,22 +1673,118 @@ export function createEditEngine(
1346
1673
  },
1347
1674
  }) as TMDataGridRowEditForm,
1348
1675
  };
1349
- entry.unmount = entry.form.mount();
1676
+ // One write for the lot rather than a `setFieldValue` per field, which
1677
+ // would churn the field meta and run the change rules on the way past.
1678
+ // `keepDefaultValues` is what keeps `original` the thing the row is
1679
+ // dirty against.
1680
+ if (values !== undefined) {
1681
+ entry.form.reset(values as never, { keepDefaultValues: true });
1682
+ }
1683
+ // Not `form.mount()`. What mount does is devtools wiring - three `window`
1684
+ // listeners per form and a state broadcast on every change - and a grid
1685
+ // holding ten thousand row forms cannot afford it: every form's every
1686
+ // change would fan out to every other form's listeners. The one thing
1687
+ // the engine wants from it is the `onMount` pass.
1688
+ if (getContext().rowValidators?.onMount !== undefined) {
1689
+ entry.form.validateSync("mount");
1690
+ }
1350
1691
  const subscription = entry.form.store.subscribe(() => publishRow(rowId));
1351
1692
  entry.unsubscribe = () => subscription.unsubscribe();
1352
1693
  forms.set(rowId, entry);
1353
- publishRow(rowId);
1694
+ staleRows.add(rowId);
1695
+ // A Set add is idempotent, so a reopened row keeps the place it had.
1696
+ if (!working.openRowIds.has(rowId)) {
1697
+ working.openRowIds.add(rowId);
1698
+ dirty.add("openRowIds");
1699
+ }
1700
+ publish();
1354
1701
  return entry;
1355
1702
  };
1356
1703
 
1704
+ /**
1705
+ * Takes a committed row back out of the draft store and into form state: a
1706
+ * fresh form over the row as it was, holding the values the user last
1707
+ * decided. `undefined` when the row is not committed.
1708
+ *
1709
+ * `committedValues` is left standing on purpose - the table keeps showing
1710
+ * the last decided values, so the row holds its place in the sort while
1711
+ * the next cell is typed into.
1712
+ */
1713
+ const reopen = (rowId: string): FormEntry | undefined => {
1714
+ const snapshot = committed.get(rowId);
1715
+ if (snapshot === undefined) return undefined;
1716
+ committed.delete(rowId);
1717
+ const entry = createForm(
1718
+ rowId,
1719
+ snapshot.original,
1720
+ snapshot.isNew,
1721
+ snapshot.values,
1722
+ );
1723
+ if (snapshot.isNew) setNewRowCommitted(rowId, false);
1724
+ else setCommitted(rowId, false);
1725
+ return entry;
1726
+ };
1727
+
1728
+ /**
1729
+ * A committed row that failed at Save: reopened, with the error on it.
1730
+ * That is what the user has to act on, and "committed" means validated, so
1731
+ * a row carrying an error cannot stay in the store.
1732
+ *
1733
+ * The error is read the way TanStack Form's own `normalizeError` reads a
1734
+ * validator's return: an object naming `form` or `fields` splits into a
1735
+ * row-level message and pathed ones, anything else is a row-level message.
1736
+ */
1737
+ const demote = (rowId: string, error: unknown) => {
1738
+ const snapshot = committed.get(rowId);
1739
+ // No snapshot: `begin` took the row back out while a per-row save was
1740
+ // still waiting on the consumer. The row is open already, and the
1741
+ // answer lands on the form it has rather than being lost.
1742
+ const entry = snapshot === undefined ? forms.get(rowId) : reopen(rowId);
1743
+ if (entry === undefined) return;
1744
+ const values =
1745
+ snapshot?.values ?? (entry.form.state.values as TMDataGridRowData);
1746
+ const shape =
1747
+ typeof error === "object" && error !== null
1748
+ ? (error as { form?: unknown; fields?: Record<string, unknown> })
1749
+ : undefined;
1750
+ const split = shape !== undefined && ("fields" in shape || "form" in shape);
1751
+ const fields = split ? shape.fields : undefined;
1752
+ const formError = split ? shape.form : error;
1753
+ if (fields !== undefined) {
1754
+ // The shape `takeFieldErrors` produces, which `project` keeps on the
1755
+ // row for as long as the value the rule tripped over stands.
1756
+ entry.submitErrors = Object.entries(fields).flatMap(([field, issue]) =>
1757
+ isValidationError(issue)
1758
+ ? [
1759
+ {
1760
+ field,
1761
+ value: getBy(values, field),
1762
+ message: firstErrorText(issue) ?? "",
1763
+ },
1764
+ ]
1765
+ : [],
1766
+ );
1767
+ }
1768
+ if (isValidationError(formError)) {
1769
+ entry.form.setErrorMap({ onSubmit: formError } as never);
1770
+ }
1771
+ publishRow(rowId);
1772
+ };
1773
+
1357
1774
  const canEditRow = (row: ErasedRow): boolean => {
1358
1775
  if (row.getIsGrouped()) return false;
1776
+ // A deletion-marked row is read-only until it is restored: an edit on a
1777
+ // row the save is about to delete has nowhere to go. The mark is checked
1778
+ // here, in the engine, so the keyboard and the verbs agree with the
1779
+ // pointer, which the table's CSS blocks.
1780
+ if (working.deletedRowIds.has(row.id)) return false;
1359
1781
  return getContext().isRowEditable?.(row) !== false;
1360
1782
  };
1361
1783
 
1362
1784
  const canEditCell = (row: ErasedRow, column: ErasedColumn): boolean => {
1363
1785
  const context = getContext();
1364
1786
  if (row.getIsGrouped()) return false;
1787
+ if (working.deletedRowIds.has(row.id)) return false;
1365
1788
  if (!isColumnEditable(column)) return false;
1366
1789
  if (!isColumnEditableForRow(column, row)) return false;
1367
1790
  if (context.isRowEditable !== undefined && !context.isRowEditable(row)) {
@@ -1401,6 +1824,8 @@ export function createEditEngine(
1401
1824
 
1402
1825
  const commit = async (rowId: string): Promise<boolean> => {
1403
1826
  const entry = forms.get(rowId);
1827
+ // No form: the row is committed already, or the grid never held it.
1828
+ // Either way there is nothing left to decide.
1404
1829
  if (entry === undefined) return true;
1405
1830
  // Enter and blur race on the same edit; the second caller joins the
1406
1831
  // first's commit instead of submitting the row twice.
@@ -1409,9 +1834,10 @@ export function createEditEngine(
1409
1834
  // Not for a new row: adding an untouched entry is still an add.
1410
1835
  if (
1411
1836
  !entry.isNew &&
1412
- diff(entry, entry.form.state.values as TMDataGridRowData).length === 0
1837
+ diff(entry.original, entry.form.state.values as TMDataGridRowData)
1838
+ .length === 0
1413
1839
  ) {
1414
- drop(rowId);
1840
+ forget(rowId);
1415
1841
  return true;
1416
1842
  }
1417
1843
  // Field errors the *form's* validator planted are cleared before the
@@ -1421,13 +1847,18 @@ export function createEditEngine(
1421
1847
  clearFormSourcedFieldErrors(entry.form);
1422
1848
  entry.lastSubmitOk = false;
1423
1849
  entry.submitErrors = [];
1424
- entry.parkOnly = getContext().draft && !savingDrafts;
1850
+ entry.parkOnly = getContext().draft;
1425
1851
  entry.pendingCommit = (async () => {
1426
1852
  try {
1427
1853
  await entry.form.handleSubmit();
1428
1854
  } finally {
1429
1855
  entry.pendingCommit = null;
1430
1856
  }
1857
+ // Dropped while the submit was in flight - cancel or deleteRow won the
1858
+ // race. The form is gone, so there is nothing to park or drop; marking
1859
+ // the row committed now would plant an id in `committedRowIds` that no
1860
+ // save or discard could ever clear.
1861
+ if (forms.get(rowId) !== entry) return true;
1431
1862
  if (!entry.lastSubmitOk) {
1432
1863
  // Snapshot before the caller closes the editor: the field errors go
1433
1864
  // with it, and the row is about to be left carrying them.
@@ -1436,49 +1867,67 @@ export function createEditEngine(
1436
1867
  return false;
1437
1868
  }
1438
1869
  if (entry.parkOnly) {
1439
- // Into the draft store: the row's values stay in its form, and the
1440
- // grid records that they are decided. An entry row renders as a
1441
- // value row, an edited row as its draft - both until `begin` takes
1442
- // the row back out into form state, or `saveDrafts` flushes it.
1443
- entry.committed = true;
1444
- if (entry.isNew) setNewRowCommitted(rowId, true);
1445
- else setCommitted(rowId, true);
1446
- store.setState((prev) =>
1447
- prev.active?.rowId === rowId ? { ...prev, active: null } : prev,
1448
- );
1870
+ // Into the draft store, as data: the values are decided, so they are
1871
+ // snapshotted and the form goes. An entry row renders as a value
1872
+ // row, an edited row as its draft - both until `begin` builds a form
1873
+ // back from the snapshot, or `saveDrafts` flushes it.
1874
+ heldSync(() => {
1875
+ const values = entry.form.state.values as TMDataGridRowData;
1876
+ committed.set(rowId, {
1877
+ original: entry.original,
1878
+ values,
1879
+ dirtyFields: diff(entry.original, values).map(
1880
+ (change) => change.field,
1881
+ ),
1882
+ isNew: entry.isNew,
1883
+ });
1884
+ if (entry.isNew) setNewRowCommitted(rowId, true);
1885
+ else setCommitted(rowId, true);
1886
+ if (working.active?.rowId === rowId) setActive(null);
1887
+ releaseForm(rowId);
1888
+ });
1449
1889
  return true;
1450
1890
  }
1451
- drop(rowId);
1891
+ forget(rowId);
1452
1892
  return true;
1453
1893
  })();
1454
1894
  return entry.pendingCommit;
1455
1895
  };
1456
1896
 
1457
1897
  const beginOn = (rowId: string, columnId: string | null) => {
1458
- // An entry row has no backing row in the table; opening one re-arms its
1459
- // editors - with `editing.draft` on, that is how a parked row is edited
1460
- // again.
1461
- const entryForm = forms.get(rowId);
1462
- if (entryForm?.isNew === true) {
1463
- entryForm.committed = false;
1464
- setNewRowCommitted(rowId, false);
1898
+ // Held: a reopen builds a form, takes the row out of the draft store and
1899
+ // names the caret's cell, and the grid should see all of it at once.
1900
+ heldSync(() => {
1901
+ // An entry row has no backing row in the table. An open one is already
1902
+ // in its editors; a committed one is re-armed from its snapshot, which
1903
+ // with `editing.draft` on is how an entered row is edited again.
1904
+ if (forms.get(rowId)?.isNew === true) {
1905
+ setActive({ rowId, columnId });
1906
+ return;
1907
+ }
1908
+ if (committed.get(rowId)?.isNew === true) {
1909
+ reopen(rowId);
1910
+ setActive({ rowId, columnId });
1911
+ return;
1912
+ }
1913
+ const row = getRow(rowId);
1914
+ if (row === undefined) return;
1915
+ if (columnId !== null) {
1916
+ const column = getContext().table.getColumn(columnId);
1917
+ if (column === undefined || !canEditCell(row, column)) return;
1918
+ } else if (!canEditRow(row)) {
1919
+ return;
1920
+ }
1921
+ // Reopening a committed row takes it back out of the draft store: what
1922
+ // the user is now editing is undecided again, and `saveDrafts` must not
1923
+ // send it until it is committed afresh. It reopens from the snapshot,
1924
+ // never from `row.original` - the table is showing the draft, and the
1925
+ // row as it was is what a commit's `changes` are diffed against.
1926
+ if (!forms.has(rowId) && reopen(rowId) === undefined) {
1927
+ createForm(rowId, row.original, false);
1928
+ }
1465
1929
  setActive({ rowId, columnId });
1466
- return;
1467
- }
1468
- const row = getRow(rowId);
1469
- if (row === undefined) return;
1470
- if (columnId !== null) {
1471
- const column = getContext().table.getColumn(columnId);
1472
- if (column === undefined || !canEditCell(row, column)) return;
1473
- } else if (getContext().isRowEditable?.(row) === false) {
1474
- return;
1475
- }
1476
- if (!forms.has(rowId)) createForm(rowId, row.original, false);
1477
- // Reopening a committed row takes it back out of the draft store: what
1478
- // the user is now editing is undecided again, and `saveDrafts` must not
1479
- // send it until it is committed afresh.
1480
- else if (forms.get(rowId)?.committed === true) setCommitted(rowId, false);
1481
- setActive({ rowId, columnId });
1930
+ });
1482
1931
  };
1483
1932
 
1484
1933
  const begin: TMDataGridEditApi["begin"] = ({ rowId, columnId }) => {
@@ -1492,15 +1941,14 @@ export function createEditEngine(
1492
1941
  // so a second row opening can neither discard the first nor be refused
1493
1942
  // by it - nothing about one row's form bears on another's |
1494
1943
  //
1495
- // A parked row is not "open": it has had its submit and is waiting for
1496
- // the save, so it is skipped rather than put through a second one. An
1944
+ // A committed row is not "open": it has had its submit and is waiting
1945
+ // for the save, so it holds no form and the sweep never sees it. An
1497
1946
  // entry row is skipped too - it is row-shaped in every mode, and its ✓
1498
1947
  // is the decision, so the sweep must not add a half-typed row.
1499
1948
  if (editMode === "cell") {
1500
- const openElsewhere = [...forms.keys()].find((id) => {
1501
- const other = forms.get(id);
1502
- return id !== rowId && other?.committed !== true && other?.isNew !== true;
1503
- });
1949
+ const openElsewhere = [...forms.keys()].find(
1950
+ (id) => id !== rowId && forms.get(id)?.isNew !== true,
1951
+ );
1504
1952
  if (openElsewhere !== undefined) {
1505
1953
  void commit(openElsewhere).then((ok) => {
1506
1954
  if (ok) beginOn(rowId, columnId);
@@ -1512,7 +1960,7 @@ export function createEditEngine(
1512
1960
  };
1513
1961
 
1514
1962
  const cancel = (rowId: string) => {
1515
- drop(rowId);
1963
+ forget(rowId);
1516
1964
  };
1517
1965
 
1518
1966
  const deactivate = () => {
@@ -1520,27 +1968,34 @@ export function createEditEngine(
1520
1968
  };
1521
1969
 
1522
1970
  const cancelAll = () => {
1523
- for (const rowId of [...forms.keys()]) drop(rowId);
1524
- store.setState((prev) => ({
1525
- ...prev,
1526
- active: null,
1527
- committedRowIds: [],
1528
- committedValues: {},
1529
- deletedRowIds: [],
1530
- }));
1971
+ heldSync(() => {
1972
+ // Open rows and committed ones alike - `openRowIds` is both.
1973
+ for (const rowId of [...working.openRowIds]) forget(rowId);
1974
+ committed.clear();
1975
+ working.active = null;
1976
+ working.committedRowIds.clear();
1977
+ working.committedValues.clear();
1978
+ working.deletedRowIds.clear();
1979
+ dirty.add("active");
1980
+ dirty.add("committedRowIds");
1981
+ dirty.add("committedValues");
1982
+ dirty.add("deletedRowIds");
1983
+ });
1531
1984
  };
1532
1985
 
1533
- const addRow = (values?: TMDataGridRowData): string => {
1986
+ /** Opens one entry row - the form and its `newRows` entry, in one write. */
1987
+ const openEntryRow = (values?: TMDataGridRowData): string => {
1534
1988
  newRowCounter += 1;
1535
- const tempId = `__new__${newRowCounter}`;
1989
+ const tempId = `${NEW_ROW_ID_PREFIX}${newRowCounter}`;
1536
1990
  createForm(tempId, seedNewRow(values), true);
1537
- store.setState((prev) => ({
1538
- ...prev,
1539
- newRows: [...prev.newRows, { tempId, committed: false }],
1540
- }));
1991
+ working.newRows.set(tempId, false);
1992
+ dirty.add("newRows");
1541
1993
  return tempId;
1542
1994
  };
1543
1995
 
1996
+ const addRow = (values?: TMDataGridRowData): string =>
1997
+ heldSync(() => openEntryRow(values));
1998
+
1544
1999
  /** Seeds one entry row's values - `newRowDefaults` under `values`. */
1545
2000
  const seedNewRow = (values?: TMDataGridRowData): TMDataGridRowData => {
1546
2001
  const { newRowDefaults } = getContext();
@@ -1556,56 +2011,74 @@ export function createEditEngine(
1556
2011
  const addRows = async (
1557
2012
  rows: ReadonlyArray<TMDataGridRowData>,
1558
2013
  options?: TMDataGridAddRowsOptions,
1559
- ): Promise<TMDataGridAddRowsResult> => {
1560
- const tempIds: Array<string> = [];
1561
- // One notification for the batch: `createForm` publishes its row as it
1562
- // mounts, so an import of hundreds would otherwise wake every subscriber
1563
- // once per row before the entry block has even been told they exist.
1564
- batch(() => {
1565
- for (const values of rows) {
1566
- newRowCounter += 1;
1567
- const tempId = `__new__${newRowCounter}`;
1568
- createForm(tempId, seedNewRow(values), true);
1569
- tempIds.push(tempId);
2014
+ ): Promise<TMDataGridAddRowsResult> =>
2015
+ // One publish for the whole import - the entry block and the table learn
2016
+ // of the rows once, committed, rather than once per row on the way in
2017
+ // and once more per commit.
2018
+ held(async () => {
2019
+ const tempIds = rows.map((values) => openEntryRow(values));
2020
+ if (options?.commit !== true) {
2021
+ return { ok: tempIds.length === 0, committed: [], open: tempIds };
1570
2022
  }
1571
- store.setState((prev) => ({
1572
- ...prev,
1573
- newRows: [
1574
- ...prev.newRows,
1575
- ...tempIds.map((tempId) => ({ tempId, committed: false })),
1576
- ],
1577
- }));
1578
- });
1579
-
1580
- if (options?.commit !== true) return { committed: [], open: tempIds };
1581
2023
 
1582
- // In order, so a consumer's `onRowAdd` sees the rows as the file had
1583
- // them. A row that fails validation stays open carrying its errors.
1584
- const committed: Array<string> = [];
1585
- const open: Array<string> = [];
1586
- for (const tempId of tempIds) {
1587
- const ok = await commit(tempId);
1588
- (ok ? committed : open).push(tempId);
1589
- }
1590
- return { committed, open };
1591
- };
2024
+ // A row that fails validation stays open carrying its errors.
2025
+ //
2026
+ // Parked, the rows are submitted together: Form runs a submit's async
2027
+ // validators off a timer, so one row after another is a timer per row
2028
+ // - minutes for an import, once the browser clamps nested timers.
2029
+ // Nothing but the engine hears a parked commit, so the rows may
2030
+ // validate side by side. Out to `onRowAdd`, one at a time and in
2031
+ // order, so the consumer sees the rows as the file had them.
2032
+ const results = getContext().draft
2033
+ ? await Promise.all(tempIds.map((tempId) => commit(tempId)))
2034
+ : [];
2035
+ if (!getContext().draft) {
2036
+ for (const tempId of tempIds) results.push(await commit(tempId));
2037
+ }
2038
+ return splitCommitted(tempIds, results);
2039
+ });
1592
2040
 
1593
2041
  const deleteRow = (rowId: string) => {
1594
- const entry = forms.get(rowId);
1595
- // Deleting an unsaved entry row is just discarding the entry.
1596
- if (entry?.isNew === true) {
1597
- drop(rowId);
2042
+ // Deleting an unsaved entry row is just discarding the entry, whether it
2043
+ // is still open or already committed into the draft store.
2044
+ if (
2045
+ forms.get(rowId)?.isNew === true ||
2046
+ committed.get(rowId)?.isNew === true
2047
+ ) {
2048
+ forget(rowId);
1598
2049
  return;
1599
2050
  }
1600
2051
  const context = getContext();
1601
2052
  if (context.draft) {
1602
- // A toggle: the second press unmarks - the mark is a draft too.
1603
- store.setState((prev) => ({
1604
- ...prev,
1605
- deletedRowIds: prev.deletedRowIds.includes(rowId)
1606
- ? prev.deletedRowIds.filter((id) => id !== rowId)
1607
- : [...prev.deletedRowIds, rowId],
1608
- }));
2053
+ // Idempotent: a marked row stays marked - `restoreRow` is the undo.
2054
+ if (working.deletedRowIds.has(rowId)) return;
2055
+ // Only a consumer row can be marked. A deletion mark is what
2056
+ // `saveDrafts` reports to the server, so an id it cannot act on - an
2057
+ // engine temp id, a record gone from `data`, an id the grid never
2058
+ // knew - must not live on as a mark inflating the draft count. Entry
2059
+ // rows are dropped above, never marked; the prefix check also catches
2060
+ // one already dropped that a stale selection or a double-fired
2061
+ // handler names again, while the table's data still shows it for one
2062
+ // render. The core model, so a filtered-out row still takes its mark.
2063
+ if (
2064
+ rowId.startsWith(NEW_ROW_ID_PREFIX) ||
2065
+ !(rowId in context.table.getCoreRowModel().rowsById)
2066
+ ) {
2067
+ return;
2068
+ }
2069
+ heldSync(() => {
2070
+ // An editor open on the row loses: what was being typed into a row
2071
+ // the user then deleted is not worth keeping, and a marked row has
2072
+ // no editor. A committed edit stays under the mark, so Restore
2073
+ // brings the row back as it was edited.
2074
+ if (forms.has(rowId)) forget(rowId);
2075
+ working.deletedRowIds.add(rowId);
2076
+ dirty.add("deletedRowIds");
2077
+ // Not selectable while marked - see `enableRowSelection` in the
2078
+ // hook - so the selection lets it go now, the way a row leaving the
2079
+ // table would. See flushRowState.
2080
+ pendingUnselect.add(rowId);
2081
+ });
1609
2082
  return;
1610
2083
  }
1611
2084
  const row = getRow(rowId);
@@ -1614,6 +2087,19 @@ export function createEditEngine(
1614
2087
  void context.onRowDelete?.({ rowId, row });
1615
2088
  };
1616
2089
 
2090
+ const deleteRows = (rowIds: ReadonlyArray<string>) => {
2091
+ // One publish for the batch - each id still goes through `deleteRow`,
2092
+ // so entry rows drop and everything else marks or no-ops by the same
2093
+ // rules.
2094
+ heldSync(() => {
2095
+ for (const rowId of rowIds) deleteRow(rowId);
2096
+ });
2097
+ };
2098
+
2099
+ const restoreRow = (rowId: string) => {
2100
+ if (working.deletedRowIds.delete(rowId)) touch("deletedRowIds");
2101
+ };
2102
+
1617
2103
  const canDeleteRows = (): boolean => {
1618
2104
  const context = getContext();
1619
2105
  if (context.draft) {
@@ -1625,151 +2111,275 @@ export function createEditEngine(
1625
2111
  return context.onRowDelete !== undefined;
1626
2112
  };
1627
2113
 
1628
- /** The pending deletions, reported and cleared by `saveDrafts`. */
1629
- const takeDeletedRowIds = (): Array<string> => {
1630
- const deleted = [...store.state.deletedRowIds];
1631
- if (deleted.length > 0) {
1632
- store.setState((prev) => ({ ...prev, deletedRowIds: [] }));
1633
- }
1634
- return deleted;
1635
- };
2114
+ /** The draft store's rows, in the order the rows entered the grid. */
2115
+ const committedIds = (): Array<string> =>
2116
+ [...working.openRowIds].filter((rowId) => committed.has(rowId));
1636
2117
 
1637
- /** The draft store's rows: committed forms, in the order they landed. */
1638
- const committedFormIds = (): Array<string> =>
1639
- [...forms.keys()].filter((rowId) => forms.get(rowId)?.committed === true);
1640
-
1641
- const commitAll = async (): Promise<boolean> => {
1642
- // Only the open ones - a row already in the draft store has had its
1643
- // submit and must not be put through a second one here.
1644
- const openIds = [...forms.keys()].filter(
1645
- (rowId) => forms.get(rowId)?.committed !== true,
2118
+ const commitAll = async (): Promise<TMDataGridCommitAllResult> => {
2119
+ // Every form there is - a row in the draft store holds none, so this is
2120
+ // the open rows and nothing else.
2121
+ const openIds = [...forms.keys()];
2122
+ const results = await held(() =>
2123
+ Promise.all(openIds.map((rowId) => commit(rowId))),
1646
2124
  );
1647
- const results = await Promise.all(openIds.map((rowId) => commit(rowId)));
1648
- return results.every(Boolean);
2125
+ return splitCommitted(openIds, results);
1649
2126
  };
1650
2127
 
1651
2128
  /**
1652
2129
  * The in-flight save. A second call while `onSaveDrafts` awaits would
1653
2130
  * re-collect the same payload and send it again - a double-clicked Save
1654
2131
  * would create every pending entry row twice - so concurrent calls join
1655
- * this promise instead of starting a save of their own.
2132
+ * this promise, and its result, instead of starting a save of their own.
1656
2133
  */
1657
- let saveInFlight: Promise<boolean> | null = null;
2134
+ let saveInFlight: Promise<TMDataGridSaveDraftsResult> | null = null;
1658
2135
 
1659
- const saveDrafts = (): Promise<boolean> => {
2136
+ const saveDrafts = (): Promise<TMDataGridSaveDraftsResult> => {
1660
2137
  if (saveInFlight !== null) return saveInFlight;
1661
- // While this runs, `commit` really commits - `editing.draft` parks
1662
- // otherwise. Single-threaded flag, same idiom as the collectors.
1663
- savingDrafts = true;
1664
2138
  saveInFlight = saveDraftsInner().finally(() => {
1665
- savingDrafts = false;
1666
2139
  saveInFlight = null;
2140
+ // Guarded: a save that found nothing to send never raised the flag,
2141
+ // so it publishes nothing either.
2142
+ if (working.isSaving) {
2143
+ working.isSaving = false;
2144
+ touch("isSaving");
2145
+ }
1667
2146
  });
1668
2147
  return saveInFlight;
1669
2148
  };
1670
2149
 
1671
- const saveDraftsInner = async (): Promise<boolean> => {
1672
- const committedIds = committedFormIds();
1673
- const deletedIds = [...store.state.deletedRowIds];
2150
+ /** What one committed row hands `onEditCommit` and the payload's `updated`. */
2151
+ const commitArgsFor = (
2152
+ rowId: string,
2153
+ snapshot: Committed,
2154
+ ): TMDataGridEditCommitArgs<TMDataGridRowData> => ({
2155
+ rowId,
2156
+ value: snapshot.values,
2157
+ original: snapshot.original,
2158
+ changes: diff(snapshot.original, snapshot.values),
2159
+ source: getContext().editMode,
2160
+ });
2161
+
2162
+ const saveDraftsInner = async (): Promise<TMDataGridSaveDraftsResult> => {
2163
+ const deletedIds = [...working.deletedRowIds];
2164
+ // A marked row's edit is not sent - the save deletes the row. The edit
2165
+ // stays under the mark for Restore and leaves with the row once the
2166
+ // deletion is saved; a rejected deletion keeps both.
2167
+ const ids = committedIds().filter(
2168
+ (rowId) => !working.deletedRowIds.has(rowId),
2169
+ );
2170
+ // Every id the save takes out of the store lands in exactly one list,
2171
+ // edits, new rows and deletions mixed, in the store's order.
2172
+ const saved: Array<string> = [];
2173
+ const kept: Array<string> = [];
2174
+ const reopened: Array<string> = [];
2175
+ const report = (): TMDataGridSaveDraftsResult => ({
2176
+ ok: kept.length === 0 && reopened.length === 0,
2177
+ saved,
2178
+ kept,
2179
+ reopened,
2180
+ });
1674
2181
  // Nothing decided: open rows are not this verb's business, so a grid
1675
2182
  // mid-edit with an empty draft store saves cleanly and stays as it is.
1676
- if (committedIds.length === 0 && deletedIds.length === 0) return true;
2183
+ if (ids.length === 0 && deletedIds.length === 0) return report();
2184
+
2185
+ working.isSaving = true;
2186
+ touch("isSaving");
2187
+
2188
+ // Only the table rules run here, and only once per row. A committed
2189
+ // row's values passed its column and row validators on the way in and
2190
+ // cannot have moved since, so re-running those could only say the same
2191
+ // thing. Table rules see the *other* rows, which later commits do move,
2192
+ // so a draft another edit has invalidated is caught here - and nowhere
2193
+ // else, because checking every committed row at every commit would be a
2194
+ // pass over the store per keystroke.
2195
+ //
2196
+ // The collection is built once for the pass: every value in it is frozen
2197
+ // while the pass runs, so N rows share one view instead of building one
2198
+ // each.
2199
+ const rows = mergedRows();
1677
2200
 
1678
2201
  if (getContext().onSaveDrafts === undefined) {
1679
2202
  // The default: the per-row loop - edits through `onEditCommit`, entry
1680
- // rows through `onRowAdd`, marked deletions through `onRowDelete`.
1681
- const results = await Promise.all(
1682
- committedIds.map((rowId) => commit(rowId)),
2203
+ // rows through `onRowAdd`, marked deletions through `onRowDelete`. The
2204
+ // rows go out side by side, as one `onSaveDrafts` call would.
2205
+ const results = await held(() =>
2206
+ Promise.all(
2207
+ ids.map(async (rowId): Promise<"saved" | "reopened" | null> => {
2208
+ const snapshot = committed.get(rowId);
2209
+ if (snapshot === undefined) return null;
2210
+ const invalid = await runTableValidators(
2211
+ snapshot.values,
2212
+ rowId,
2213
+ snapshot.isNew,
2214
+ rows,
2215
+ );
2216
+ if (isValidationError(invalid)) {
2217
+ demote(rowId, invalid);
2218
+ return "reopened";
2219
+ }
2220
+ try {
2221
+ if (snapshot.isNew) {
2222
+ await getContext().onRowAdd?.({
2223
+ tempId: rowId,
2224
+ value: snapshot.values,
2225
+ });
2226
+ } else {
2227
+ await getContext().onEditCommit?.(
2228
+ commitArgsFor(rowId, snapshot),
2229
+ );
2230
+ }
2231
+ } catch (error) {
2232
+ // The same answer a rejected commit gets outside the draft
2233
+ // store: the row is open again, carrying the message.
2234
+ demote(
2235
+ rowId,
2236
+ error instanceof Error ? error.message : String(error),
2237
+ );
2238
+ return "reopened";
2239
+ }
2240
+ forget(rowId);
2241
+ return "saved";
2242
+ }),
2243
+ ),
1683
2244
  );
1684
- for (const rowId of takeDeletedRowIds()) {
2245
+ ids.forEach((rowId, index) => {
2246
+ const outcome = results[index];
2247
+ if (outcome === "saved") saved.push(rowId);
2248
+ else if (outcome === "reopened") reopened.push(rowId);
2249
+ });
2250
+ // A mark leaves once its `onRowDelete` resolved, and the row's
2251
+ // selection and the edit held under the mark go with it in one publish -
2252
+ // see flushRowState and saveDraftsInner's `ids`. A throw keeps the mark
2253
+ // for the next save, as a deletion `onSaveDrafts` refuses is kept.
2254
+ const deletedOk: Array<string> = [];
2255
+ for (const rowId of deletedIds) {
1685
2256
  const row = getRow(rowId);
1686
- if (row !== undefined) {
1687
- await getContext().onRowDelete?.({ rowId, row });
2257
+ try {
2258
+ if (row !== undefined) {
2259
+ await getContext().onRowDelete?.({ rowId, row });
2260
+ }
2261
+ deletedOk.push(rowId);
2262
+ } catch {
2263
+ kept.push(rowId);
1688
2264
  }
1689
2265
  }
1690
- return results.every(Boolean);
2266
+ heldSync(() => {
2267
+ let deletionsChanged = false;
2268
+ for (const rowId of deletedOk) {
2269
+ if (working.deletedRowIds.delete(rowId)) deletionsChanged = true;
2270
+ pendingLeft.add(rowId);
2271
+ if (committed.has(rowId)) forget(rowId);
2272
+ }
2273
+ if (deletionsChanged) dirty.add("deletedRowIds");
2274
+ });
2275
+ saved.push(...deletedOk);
2276
+ return report();
1691
2277
  }
1692
2278
 
1693
- // One consumer call for the lot. Validation stays Form's, per row: rows
1694
- // that fail keep their forms and markers; the valid ones travel
1695
- // together, and only a resolved save drops them - a rejected save keeps
1696
- // every draft, deletions included.
1697
- const collected: Array<TMDataGridEditCommitArgs<TMDataGridRowData>> = [];
1698
- const added: Array<TMDataGridRowAddArgs<TMDataGridRowData>> = [];
1699
- draftCollector = collected;
1700
- draftAddCollector = added;
1701
- let allValid = true;
1702
- try {
1703
- for (const rowId of committedIds) {
1704
- const entry = forms.get(rowId);
1705
- if (entry === undefined) continue;
1706
- entry.lastSubmitOk = false;
1707
- await entry.form.handleSubmit();
1708
- if (!entry.lastSubmitOk) allValid = false;
1709
- }
1710
- } finally {
1711
- draftCollector = null;
1712
- draftAddCollector = null;
1713
- }
2279
+ // One consumer call for the lot. The valid rows travel together, and
2280
+ // only a resolved save drops them - a rejected save keeps every draft,
2281
+ // deletions included. A row the table rules now reject is reopened with
2282
+ // the error and left behind for the user to fix.
2283
+ type CommitArgs = TMDataGridEditCommitArgs<TMDataGridRowData>;
2284
+ type AddArgs = TMDataGridRowAddArgs<TMDataGridRowData>;
2285
+ const collected: Array<CommitArgs> = [];
2286
+ const added: Array<AddArgs> = [];
2287
+ // The rows sent, edits and new rows mixed in the store's order - the
2288
+ // order the result reports them in.
2289
+ const sent: Array<{ id: string; isNew: boolean }> = [];
2290
+ await held(async () => {
2291
+ const results = await Promise.all(
2292
+ ids.map((rowId) => {
2293
+ const snapshot = committed.get(rowId);
2294
+ if (snapshot === undefined) return undefined;
2295
+ return runTableValidators(
2296
+ snapshot.values,
2297
+ rowId,
2298
+ snapshot.isNew,
2299
+ rows,
2300
+ );
2301
+ }),
2302
+ );
2303
+ // In the store's order, which is the order the rows entered the grid.
2304
+ ids.forEach((rowId, index) => {
2305
+ const snapshot = committed.get(rowId);
2306
+ if (snapshot === undefined) return;
2307
+ if (isValidationError(results[index])) {
2308
+ demote(rowId, results[index]);
2309
+ reopened.push(rowId);
2310
+ return;
2311
+ }
2312
+ sent.push({ id: rowId, isNew: snapshot.isNew });
2313
+ if (snapshot.isNew) {
2314
+ added.push({ tempId: rowId, value: snapshot.values });
2315
+ } else {
2316
+ collected.push(commitArgsFor(rowId, snapshot));
2317
+ }
2318
+ });
2319
+ });
1714
2320
  const deleted = deletedIds;
1715
2321
  if (collected.length > 0 || added.length > 0 || deleted.length > 0) {
1716
- let result: void | TMDataGridSaveDraftsResult;
2322
+ let response: void | TMDataGridSaveDraftsResponse;
1717
2323
  try {
1718
- result = await getContext().onSaveDrafts?.({
2324
+ response = await getContext().onSaveDrafts?.({
1719
2325
  updated: collected,
1720
2326
  created: added,
1721
2327
  deleted,
1722
- // The pre-2.0 names, still filled - see TMDataGridSaveDraftsArgs.
1723
- rows: collected,
1724
- added,
1725
2328
  });
1726
2329
  } catch {
1727
- return false;
2330
+ // A thrown save keeps everything it was sent, for the next save.
2331
+ kept.push(...sent.map((row) => row.id), ...deleted);
2332
+ return report();
1728
2333
  }
1729
2334
 
1730
- // Nothing returned saves the lot. A result names what failed; those
2335
+ // Nothing returned saves the lot. A response names what failed; those
1731
2336
  // keep their drafts, committed, so the next save retries them.
1732
- const outcomes = result ?? {};
1733
- let savedAll = true;
1734
-
1735
- for (const args of collected) {
1736
- if (isSaved(outcomes.updated, args.rowId)) drop(args.rowId);
1737
- else savedAll = false;
1738
- }
1739
- for (const args of added) {
1740
- if (isSaved(outcomes.created, args.tempId)) drop(args.tempId);
1741
- else savedAll = false;
1742
- }
1743
- const savedDeletions = deleted.filter((id) =>
1744
- isSaved(outcomes.deleted, id),
1745
- );
1746
- if (savedDeletions.length > 0) {
1747
- store.setState((prev) => ({
1748
- ...prev,
1749
- deletedRowIds: prev.deletedRowIds.filter(
1750
- (id) => !savedDeletions.includes(id),
1751
- ),
1752
- }));
1753
- }
1754
- if (savedDeletions.length < deleted.length) savedAll = false;
1755
-
1756
- if (!savedAll) return false;
2337
+ const outcomes = response ?? {};
2338
+
2339
+ // The saved rows leave the store in one publish.
2340
+ heldSync(() => {
2341
+ for (const { id, isNew } of sent) {
2342
+ if (isSaved(isNew ? outcomes.created : outcomes.updated, id)) {
2343
+ forget(id);
2344
+ saved.push(id);
2345
+ } else {
2346
+ kept.push(id);
2347
+ }
2348
+ }
2349
+ let deletionsChanged = false;
2350
+ for (const id of deleted) {
2351
+ if (!isSaved(outcomes.deleted, id)) {
2352
+ kept.push(id);
2353
+ continue;
2354
+ }
2355
+ saved.push(id);
2356
+ if (working.deletedRowIds.delete(id)) deletionsChanged = true;
2357
+ // The consumer has deleted the record; the row is on its way out
2358
+ // of `data`, and its selection goes now - see flushRowState. So
2359
+ // does the edit held under the mark.
2360
+ pendingLeft.add(id);
2361
+ if (committed.has(id)) forget(id);
2362
+ }
2363
+ if (deletionsChanged) dirty.add("deletedRowIds");
2364
+ });
1757
2365
  }
1758
- return allValid;
1759
- };
1760
-
1761
- /** @deprecated The old one-shot save - `commitAll` then `saveDrafts`. */
1762
- const submitAll = async (): Promise<boolean> => {
1763
- const committedOk = await commitAll();
1764
- const savedOk = await saveDrafts();
1765
- return committedOk && savedOk;
2366
+ return report();
1766
2367
  };
1767
2368
 
1768
2369
  /**
1769
2370
  * The write path behind `clearCell`, `setCellValue` and `setRowValues`:
1770
2371
  * fill the row's own form and commit it, exactly as a ✓ on an open editor
1771
2372
  * would. Uses the form already open on the row when there is one, so a
1772
- * programmatic write joins an edit in progress rather than discarding it.
2373
+ * programmatic write joins an edit in progress rather than discarding it;
2374
+ * a committed row is reopened from its snapshot and commits afresh.
2375
+ *
2376
+ * Only that last case runs under a hold. Reopened, the row is out of the
2377
+ * draft store until it commits again, and its markers must not flicker
2378
+ * off for the length of the validators. The hold is safe there because a
2379
+ * committed row exists only under `editing.draft`, where the commit parks
2380
+ * and never waits on the consumer. An open row's commit without `draft`
2381
+ * awaits `onEditCommit`, and holding the engine's publish for a server
2382
+ * round trip would stall every other row's markers and the caret.
1773
2383
  *
1774
2384
  * No editor is involved, so `meta.edit.mapValue` does not run - the caller
1775
2385
  * writes the stored value itself. Validation is untouched: this is the same
@@ -1783,12 +2393,24 @@ export function createEditEngine(
1783
2393
  ): Promise<boolean> => {
1784
2394
  const row = getRow(rowId);
1785
2395
  if (row === undefined) return false;
2396
+ // Read-only while marked - see canEditRow.
2397
+ if (working.deletedRowIds.has(rowId)) return false;
1786
2398
  if (writes.length === 0) return true;
1787
- const entry = forms.get(rowId) ?? createForm(rowId, row.original, false);
1788
- for (const { field, value } of writes) {
1789
- entry.form.setFieldValue(field as never, value as never);
2399
+ const write = (entry: FormEntry): Promise<boolean> => {
2400
+ for (const { field, value } of writes) {
2401
+ entry.form.setFieldValue(field as never, value as never);
2402
+ }
2403
+ return commit(rowId);
2404
+ };
2405
+ const open = forms.get(rowId);
2406
+ if (open !== undefined) return write(open);
2407
+ if (!committed.has(rowId)) {
2408
+ return write(createForm(rowId, row.original, false));
1790
2409
  }
1791
- return commit(rowId);
2410
+ return held(async () => {
2411
+ const entry = reopen(rowId);
2412
+ return entry === undefined ? false : write(entry);
2413
+ });
1792
2414
  };
1793
2415
 
1794
2416
  /** The field a cell writes to, or `null` when that cell takes no edit. */
@@ -1840,49 +2462,73 @@ export function createEditEngine(
1840
2462
  return writeFields(rowId, writes);
1841
2463
  };
1842
2464
 
1843
- const getRowValues = (rowId: string): TMDataGridRowData | undefined => {
1844
- const held = forms.get(rowId);
1845
- // Entry rows exist only as forms, so this covers them too.
1846
- if (held !== undefined) return held.form.state.values as TMDataGridRowData;
1847
- return getRow(rowId)?.original as TMDataGridRowData | undefined;
1848
- };
2465
+ const getRowValues = (rowId: string): TMDataGridRowData | undefined =>
2466
+ // An entry row exists only as a draft, open or committed, so this covers
2467
+ // it too.
2468
+ draftValues(rowId) ??
2469
+ (getRow(rowId)?.original as TMDataGridRowData | undefined);
1849
2470
 
1850
2471
  const getRows = (): ReadonlyArray<TMDataGridEditRowSnapshot> => {
1851
- const deleted = new Set(store.state.deletedRowIds);
2472
+ const deleted = working.deletedRowIds;
1852
2473
  const rows: Array<TMDataGridEditRowSnapshot> = [];
1853
2474
  const model = getContext().table.getCoreRowModel();
1854
2475
  for (const row of model.flatRows) {
1855
- const held = forms.get(row.id);
1856
2476
  rows.push({
1857
2477
  rowId: row.id,
1858
- value:
1859
- held === undefined
1860
- ? (row.original as TMDataGridRowData)
1861
- : (held.form.state.values as TMDataGridRowData),
1862
- isNew: held?.isNew === true,
2478
+ value: draftValues(row.id) ?? (row.original as TMDataGridRowData),
2479
+ isNew:
2480
+ forms.get(row.id)?.isNew ?? committed.get(row.id)?.isNew ?? false,
1863
2481
  deleted: deleted.has(row.id),
1864
2482
  });
1865
2483
  }
1866
2484
  // Entry rows the table does not hold - see mergedRows.
1867
- for (const newRow of store.state.newRows) {
1868
- if (newRow.tempId in model.rowsById) continue;
1869
- const held = forms.get(newRow.tempId);
1870
- if (held === undefined) continue;
2485
+ for (const tempId of working.newRows.keys()) {
2486
+ if (tempId in model.rowsById) continue;
2487
+ const values = draftValues(tempId);
2488
+ if (values === undefined) continue;
1871
2489
  rows.push({
1872
- rowId: newRow.tempId,
1873
- value: held.form.state.values as TMDataGridRowData,
2490
+ rowId: tempId,
2491
+ value: values,
1874
2492
  isNew: true,
1875
- deleted: deleted.has(newRow.tempId),
2493
+ deleted: deleted.has(tempId),
1876
2494
  });
1877
2495
  }
1878
2496
  return rows;
1879
2497
  };
1880
2498
 
2499
+ /**
2500
+ * Drops what the store holds for records that left `data`: a refetch that
2501
+ * no longer returns a row takes its open form, its committed edit and its
2502
+ * deletion mark with it. The server has nothing for the save to update or
2503
+ * delete, and a row nobody can see must not sit in the Save count. Entry
2504
+ * rows are the engine's own and stay. Called by the hook whenever the rows
2505
+ * as shown change, with the core row model's `rowsById`; not called where
2506
+ * the grid does not own the result set, since there a row missing from
2507
+ * `data` is on another page, not gone.
2508
+ */
2509
+ const forgetMissingRows = (rowsById: Record<string, unknown>) => {
2510
+ heldSync(() => {
2511
+ // Open rows and committed ones alike - `openRowIds` is both.
2512
+ for (const rowId of [...working.openRowIds]) {
2513
+ const isNew =
2514
+ forms.get(rowId)?.isNew ?? committed.get(rowId)?.isNew ?? false;
2515
+ if (!isNew && !(rowId in rowsById)) forget(rowId);
2516
+ }
2517
+ for (const rowId of [...working.deletedRowIds]) {
2518
+ if (rowId in rowsById) continue;
2519
+ working.deletedRowIds.delete(rowId);
2520
+ dirty.add("deletedRowIds");
2521
+ }
2522
+ });
2523
+ };
2524
+
1881
2525
  return {
1882
2526
  store,
1883
2527
  get state() {
1884
2528
  return store.state;
1885
2529
  },
2530
+ forgetMissingRows,
2531
+ isRowDeleted: (rowId) => working.deletedRowIds.has(rowId),
1886
2532
  getForm: (rowId) => forms.get(rowId)?.form,
1887
2533
  getRowValues,
1888
2534
  getRows,
@@ -1896,13 +2542,14 @@ export function createEditEngine(
1896
2542
  cancelAll,
1897
2543
  commitAll,
1898
2544
  saveDrafts,
1899
- submitAll,
1900
2545
  clearCell,
1901
2546
  setCellValue,
1902
2547
  setRowValues,
1903
2548
  addRow,
1904
2549
  addRows,
1905
2550
  deleteRow,
2551
+ deleteRows,
2552
+ restoreRow,
1906
2553
  canDeleteRows,
1907
2554
  };
1908
2555
  }