@jielga/tmdatagrid 2.0.0-beta.8 → 2.0.0

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 (155) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1323 -796
  3. package/dist/index.js +4719 -3193
  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 +268 -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 +69 -78
  47. package/skills/columns/SKILL.md +90 -34
  48. package/skills/data/SKILL.md +86 -16
  49. package/skills/editing/SKILL.md +83 -50
  50. package/skills/editing/references/common-mistakes.md +77 -69
  51. package/skills/editing/references/editing-api.md +31 -23
  52. package/skills/editing/references/editors-and-validation.md +24 -17
  53. package/skills/filtering/SKILL.md +148 -40
  54. package/skills/getting-started/SKILL.md +17 -15
  55. package/skills/grouping/SKILL.md +31 -16
  56. package/skills/options/SKILL.md +8 -8
  57. package/skills/rows/SKILL.md +22 -18
  58. package/skills/server-side/SKILL.md +170 -17
  59. package/skills/testing/SKILL.md +150 -32
  60. package/skills/testing-components/SKILL.md +230 -0
  61. package/skills/testing-editing/SKILL.md +240 -0
  62. package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
  63. package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
  64. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +38 -21
  65. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +73 -7
  66. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  67. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  68. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +9 -55
  69. package/src/{tmdatagrid/components → components}/TMDataGridDraftActions.tsx +41 -24
  70. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +23 -63
  71. package/src/components/TMDataGridEntryRows.tsx +354 -0
  72. package/src/components/TMDataGridExportPicker.module.css +77 -0
  73. package/src/components/TMDataGridExportPicker.tsx +234 -0
  74. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  75. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  76. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +15 -8
  77. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  78. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  79. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -90
  80. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +9 -72
  81. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +12 -2
  82. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +58 -21
  83. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  84. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  85. package/src/components/TMDataGridMenu.tsx +357 -0
  86. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +15 -53
  87. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +88 -65
  88. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +579 -165
  89. package/src/{tmdatagrid/components → components}/TMDataGridToolbar.module.css +5 -0
  90. package/src/components/TMDataGridToolbar.tsx +181 -0
  91. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  92. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  93. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  94. package/src/{tmdatagrid/components → components}/editors/TMDataGridNumberEditor.tsx +3 -3
  95. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  96. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  97. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +16 -2
  98. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  99. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  100. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  101. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  102. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  103. package/src/components/filters/controlLayout.ts +32 -0
  104. package/src/components/filters/filterControlFor.ts +65 -0
  105. package/src/components/generatedColumns.tsx +187 -0
  106. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  107. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  108. package/src/components/useHideableColumns.ts +52 -0
  109. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  110. package/src/{tmdatagrid/core → core}/capabilities.ts +5 -5
  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 +1172 -388
  118. package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
  119. package/src/core/export.ts +704 -0
  120. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  121. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  122. package/src/core/filterSurface.ts +99 -0
  123. package/src/{tmdatagrid/core → core}/grouping.ts +21 -0
  124. package/src/{tmdatagrid/core → core}/labels.ts +51 -6
  125. package/src/{tmdatagrid/core → core}/labelsSv.ts +27 -6
  126. package/src/core/pageReset.ts +120 -0
  127. package/src/core/pagination.ts +81 -0
  128. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  129. package/src/{tmdatagrid/core → core}/summary.ts +20 -4
  130. package/src/{tmdatagrid/index.ts → index.ts} +70 -36
  131. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +534 -123
  132. package/src/useTMDataGridExport.ts +78 -0
  133. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +0 -298
  134. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  135. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  136. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -161
  137. package/src/tmdatagrid/core/cellExport.ts +0 -320
  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}/cellNavigation.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/controlledState.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  151. /package/src/{tmdatagrid/core → core}/resizePreview.ts +0 -0
  152. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  153. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  154. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
  155. /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,31 +196,52 @@ 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>;
217
+ /**
218
+ * The draft store's values, per row - what a committed row *is* to the
219
+ * table. Snapshotted when a row commits (existing and entry rows alike)
220
+ * and kept across a reopen until the row commits again or is dropped, so
221
+ * a row keeps its place in the sort while a second cell is being typed
222
+ * into. The hook feeds these into the table's `data` in place of the
223
+ * consumer's records, which is how sorting, filtering, grouping and
224
+ * aggregates see a draft.
225
+ */
226
+ committedValues: Readonly<Record<string, TMDataGridRowData>>;
213
227
  /**
214
228
  * Rows being created, not yet in `data`. `committed` is the draft store's
215
229
  * add slice: the entry row passed its submit and renders as a value row
216
- * until `begin` re-opens it. Without `editing.draft` a commit adds through
217
- * `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`.
218
233
  */
219
234
  newRows: ReadonlyArray<{ tempId: string; committed: boolean }>;
220
235
  /** The draft store's delete slice: rows marked deleted, awaiting the save. */
221
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;
222
245
  };
223
246
 
224
247
  const EMPTY_EDIT_STATE: TMDataGridEditState = {
@@ -226,31 +249,57 @@ const EMPTY_EDIT_STATE: TMDataGridEditState = {
226
249
  openRowIds: [],
227
250
  rows: {},
228
251
  committedRowIds: [],
252
+ committedValues: {},
229
253
  newRows: [],
230
254
  deletedRowIds: [],
255
+ isSaving: false,
231
256
  };
232
257
 
233
258
  /**
234
259
  * The rows still *open*: holding a live form nobody has decided yet.
235
260
  *
236
261
  * Narrower than {@link TMDataGridEditState.openRowIds}, which is every row
237
- * with a form, the parked ones included. A row qualifies here when it is not
238
- * parked in the draft store and there is something to lose - an entered row
239
- * 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.
240
265
  *
241
266
  * The order is the engine's: the order the forms were opened.
242
267
  */
243
268
  export function getOpenRowIds(
244
269
  state: TMDataGridEditState,
245
270
  ): ReadonlyArray<string> {
246
- return state.openRowIds.filter(
247
- (rowId) =>
248
- !state.committedRowIds.includes(rowId) &&
249
- !state.newRows.some(
250
- (newRow) => newRow.tempId === rowId && newRow.committed,
251
- ) &&
252
- (state.newRows.some((newRow) => newRow.tempId === rowId) ||
253
- (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
+ )
254
303
  );
255
304
  }
256
305
 
@@ -337,15 +386,16 @@ export type TMDataGridEditValueMap = (
337
386
  * `meta.type` and `meta.options` stay outside this namespace on purpose: one
338
387
  * declaration of each feeds the cell editor and the filter panel alike.
339
388
  */
340
- export type TMDataGridColumnEditOptions = {
389
+ export type TMDataGridColumnEditOptions<
390
+ TData extends RowData = TMDataGridRowData,
391
+ > = {
341
392
  /**
342
393
  * Whether this column's cells take edits, once `editMode` is on. `false`
343
394
  * switches the column off outright; a predicate decides per row. Defaults
344
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.
345
397
  */
346
- enabled?:
347
- | boolean
348
- | ((row: Row<TMDataGridFeatures, TMDataGridRowData>) => boolean);
398
+ enabled?: boolean | ((row: Row<TMDataGridFeatures, TData>) => boolean);
349
399
  /**
350
400
  * The data path this column edits, when it is not the `accessorKey` - the
351
401
  * only way a column built on `accessorFn` becomes editable. Dot paths reach
@@ -396,10 +446,6 @@ export type TMDataGridSaveDraftsArgs<TData extends RowData> = {
396
446
  created: Array<TMDataGridRowAddArgs<TData>>;
397
447
  /** Ids marked deleted while the drafts accumulated. */
398
448
  deleted: Array<string>;
399
- /** @deprecated Renamed to {@link updated}. Removed in a later beta. */
400
- rows: Array<TMDataGridEditCommitArgs<TData>>;
401
- /** @deprecated Renamed to {@link created}. Removed in a later beta. */
402
- added: Array<TMDataGridRowAddArgs<TData>>;
403
449
  };
404
450
 
405
451
  /**
@@ -418,7 +464,7 @@ export type TMDataGridSaveOutcomes = boolean | Record<string, boolean>;
418
464
  * with nothing beyond the state itself - a failed edit keeps `data-draft`,
419
465
  * a failed deletion keeps `data-deleted` - so the display is the consumer's.
420
466
  */
421
- export type TMDataGridSaveDraftsResult = {
467
+ export type TMDataGridSaveDraftsResponse = {
422
468
  /** Keyed by `rowId`. */
423
469
  updated?: TMDataGridSaveOutcomes;
424
470
  /** Keyed by `tempId`. */
@@ -437,13 +483,6 @@ function isSaved(
437
483
  return outcomes[id] !== false;
438
484
  }
439
485
 
440
- /**
441
- * @deprecated Renamed to {@link TMDataGridSaveDraftsArgs} - the payload is
442
- * the draft store being saved, not a commit. Removed in a later beta.
443
- */
444
- export type TMDataGridEditCommitDraftsArgs<TData extends RowData> =
445
- TMDataGridSaveDraftsArgs<TData>;
446
-
447
486
  /** What the engine reads fresh on every call - see `createEditEngine`. */
448
487
  export type TMDataGridEditEngineContext = {
449
488
  table: TMDataGridTable<TMDataGridRowData>;
@@ -465,8 +504,8 @@ export type TMDataGridEditEngineContext = {
465
504
  args: TMDataGridSaveDraftsArgs<TMDataGridRowData>,
466
505
  ) =>
467
506
  | void
468
- | TMDataGridSaveDraftsResult
469
- | Promise<void | TMDataGridSaveDraftsResult>;
507
+ | TMDataGridSaveDraftsResponse
508
+ | Promise<void | TMDataGridSaveDraftsResponse>;
470
509
  /**
471
510
  * Seed values for `addRow`, under the values it is called with. A function
472
511
  * is called per added row.
@@ -526,6 +565,8 @@ export type TMDataGridAddRowsOptions = {
526
565
 
527
566
  /** What `edit.addRows` reports back. Every added row is in exactly one list. */
528
567
  export type TMDataGridAddRowsResult = {
568
+ /** `true` when every added row committed - `open` is empty. */
569
+ ok: boolean;
529
570
  /** Temp ids that committed - parked as drafts, or added outright. */
530
571
  committed: Array<string>;
531
572
  /**
@@ -535,12 +576,90 @@ export type TMDataGridAddRowsResult = {
535
576
  open: Array<string>;
536
577
  };
537
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
+
641
+ /** One row of {@link TMDataGridEditApi.getRows}. */
642
+ export type TMDataGridEditRowSnapshot<
643
+ TData extends RowData = TMDataGridRowData,
644
+ > = {
645
+ /** The row's id - `addRow`'s temp id for an entry row. */
646
+ rowId: string;
647
+ /** The row as shown: its draft where a form holds one, else `data`'s value. */
648
+ value: TData;
649
+ /** An entry row, not yet in `data`. */
650
+ isNew: boolean;
651
+ /** Marked deleted, awaiting `saveDrafts`. */
652
+ deleted: boolean;
653
+ };
654
+
538
655
  /**
539
656
  * The engine plus its store - `api.edit`.
540
657
  *
541
- * "One row, one form": `getForm` hands out the same `FormApi` the inline
542
- * editors write through, so a consumer can render it in a drawer or a detail
543
- * 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.
544
663
  */
545
664
  export type TMDataGridEditApi<
546
665
  TData extends RowData = TMDataGridRowData,
@@ -549,8 +668,26 @@ export type TMDataGridEditApi<
549
668
  store: Store<TMDataGridEditState>;
550
669
  /** Current snapshot, for reads outside React. */
551
670
  readonly state: TMDataGridEditState;
552
- /** 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
+ */
553
676
  getForm: (rowId: string) => TMDataGridRowEditForm | undefined;
677
+ /**
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.
682
+ */
683
+ getRowValues: (rowId: string) => TData | undefined;
684
+ /**
685
+ * Every row as shown, nothing filtered out: data rows overlaid with their
686
+ * drafts, entry rows appended, deletion-marked rows included and flagged.
687
+ * Built from the core row model, so it is unfiltered, unsorted and never
688
+ * contains group rows. Filter on `deleted` / `isNew` for the set you want.
689
+ */
690
+ getRows: () => ReadonlyArray<TMDataGridEditRowSnapshot<TData>>;
554
691
  /**
555
692
  * Whether this cell may open an editor: the column maps to a field, nothing
556
693
  * switched it off, and the row takes edits at all.
@@ -590,24 +727,33 @@ export type TMDataGridEditApi<
590
727
  * Submits every open row, as if each had been OK'd: a row that validates
591
728
  * commits (into the draft store with `editing.draft` on, straight to the
592
729
  * consumer without it), a row that fails stays open with its errors.
593
- * `true` when every row committed. Under `editing.draft` it sends nothing
594
- * 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}.
595
736
  */
596
- commitAll: () => Promise<boolean>;
737
+ commitAll: () => Promise<TMDataGridCommitAllResult>;
597
738
  /**
598
739
  * Flushes the draft store: every committed edit, added row and deletion
599
740
  * mark reaches the consumer, through `onSaveDrafts` in one call when it is
600
741
  * set, or row by row through `onCommit` / `onRowAdd` / `onRowDelete`.
601
742
  *
602
743
  * Rows still open are left alone - they keep their form state and stay
603
- * open. `true` when everything landed; a rejected save keeps every draft.
604
- */
605
- saveDrafts: () => Promise<boolean>;
606
- /**
607
- * @deprecated Split into {@link commitAll} and {@link saveDrafts}, which is
608
- * 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}.
609
755
  */
610
- submitAll: () => Promise<boolean>;
756
+ saveDrafts: () => Promise<TMDataGridSaveDraftsResult>;
611
757
  /** Writes the type's empty value into a cell and commits it - Delete. */
612
758
  clearCell: (rowId: string, columnId: string) => Promise<boolean>;
613
759
  /**
@@ -652,15 +798,19 @@ export type TMDataGridEditApi<
652
798
  */
653
799
  addRow: (values?: Partial<TData>) => string;
654
800
  /**
655
- * 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
656
802
  * batch, where a loop over `addRow` is one per row. Each row is seeded over
657
803
  * `newRowDefaults` exactly as `addRow` does.
658
804
  *
659
- * `commit: true` submits each row as it lands, which is what an import
660
- * wants: rows that validate commit (parked in the draft store under
661
- * `editing.draft`, added through `onRowAdd` without it - once per row),
662
- * and rows that fail stay open in the entry block carrying their errors,
663
- * 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.
664
814
  */
665
815
  addRows: (
666
816
  rows: ReadonlyArray<Partial<TData>>,
@@ -668,11 +818,26 @@ export type TMDataGridEditApi<
668
818
  ) => Promise<TMDataGridAddRowsResult>;
669
819
  /**
670
820
  * Deletes a row: `onRowDelete` straight away, or under `editing.draft` a
671
- * toggle of the id in `deletedRowIds` - the row renders struck through
672
- * until `saveDrafts` reports it. On an uncommitted entry row it just
673
- * 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.
674
826
  */
675
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;
676
841
  /** Whether delete chrome makes sense - the lane's trash gate. */
677
842
  canDeleteRows: () => boolean;
678
843
  };
@@ -796,22 +961,36 @@ async function runFieldValidator(
796
961
  * form keeps its values, meta and errors; scroll back and the editor
797
962
  * re-mounts over the same form.
798
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
+
799
979
  export function createEditEngine(
800
980
  getContext: () => TMDataGridEditEngineContext,
801
- ): TMDataGridEditApi {
981
+ ): TMDataGridEditEngine {
802
982
  const store = new Store<TMDataGridEditState>(EMPTY_EDIT_STATE);
803
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
+ */
804
989
  type FormEntry = {
805
990
  form: TMDataGridRowEditForm;
806
991
  original: TMDataGridRowData;
807
992
  /** An entry-block row - a form with no backing row yet. */
808
993
  isNew: boolean;
809
- /**
810
- * In the draft store: this row's form passed its submit and is parked,
811
- * waiting for `saveDrafts`. Mirrored into the state's `committedRowIds`
812
- * (existing rows) or `newRows[].committed` (entry rows).
813
- */
814
- committed: boolean;
815
994
  /** Set by the wrapped onSubmit when the consumer's commit resolved. */
816
995
  lastSubmitOk: boolean;
817
996
  /**
@@ -823,31 +1002,35 @@ export function createEditEngine(
823
1002
  submitErrors: Array<{ field: string; value: unknown; message: string }>;
824
1003
  /**
825
1004
  * The park: this submit validates and puts the row in the draft store
826
- * instead of calling the consumer. Set by `commit` per attempt - `true`
827
- * only while `editing.draft` is on and outside `saveDrafts`, which is
828
- * what closes the per-row escape hatches (the lane's ✓,
829
- * Delete-to-clear) at the engine.
1005
+ * instead of calling the consumer. Set by `commit` per attempt, from
1006
+ * `editing.draft`.
830
1007
  */
831
1008
  parkOnly: boolean;
832
1009
  /** A commit already running - Enter and blur race on the same edit. */
833
1010
  pendingCommit: Promise<boolean> | null;
834
1011
  unsubscribe: () => void;
835
- unmount: () => void;
836
1012
  };
837
1013
  const forms = new Map<string, FormEntry>();
838
- let newRowCounter = 0;
839
- /** Lets `commit` tell a `saveDrafts` flush apart from a lone commit. */
840
- let savingDrafts = false;
841
1014
 
842
1015
  /**
843
- * While `saveDrafts` runs with an `onSaveDrafts`, each row's wrapped
844
- * onSubmit contributes its args here instead of calling `onEditCommit` -
845
- * 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.
846
1021
  */
847
- let draftCollector: Array<TMDataGridEditCommitArgs<TMDataGridRowData>> | null =
848
- null;
849
- let draftAddCollector: Array<TMDataGridRowAddArgs<TMDataGridRowData>> | null =
850
- 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__";
851
1034
 
852
1035
  /**
853
1036
  * The column's half of the rule, with no row in hand: it is a consumer
@@ -907,45 +1090,62 @@ export function createEditEngine(
907
1090
  return Object.keys(fields).length > 0 ? fields : undefined;
908
1091
  };
909
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
+
910
1105
  /**
911
1106
  * The collection as a table validator sees it: every data row overlaid
912
- * with its draft where a form holds one, entry rows appended, and rows
913
- * marked deleted removed. The committing row contributes the values being
914
- * submitted, not what `data` still says. Built from the core row model, so
915
- * 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.
916
1112
  */
917
- const mergedRows = (
918
- rowId: string,
919
- value: TMDataGridRowData,
920
- isNew: boolean,
921
- ): Array<{ rowId: string; value: TMDataGridRowData }> => {
922
- 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;
923
1119
  const rows: Array<{ rowId: string; value: TMDataGridRowData }> = [];
924
- for (const row of getContext().table.getCoreRowModel().flatRows) {
1120
+ const model = getContext().table.getCoreRowModel();
1121
+ for (const row of model.flatRows) {
925
1122
  if (deleted.has(row.id)) continue;
926
- if (row.id === rowId) {
927
- rows.push({ rowId, value });
1123
+ if (row.id === committing?.rowId) {
1124
+ rows.push({ rowId: row.id, value: committing.value });
928
1125
  continue;
929
1126
  }
930
- const held = forms.get(row.id);
931
1127
  rows.push({
932
1128
  rowId: row.id,
933
- value:
934
- held === undefined
935
- ? (row.original as TMDataGridRowData)
936
- : (held.form.state.values as TMDataGridRowData),
1129
+ value: draftValues(row.id) ?? (row.original as TMDataGridRowData),
937
1130
  });
938
1131
  }
939
- for (const newRow of store.state.newRows) {
940
- if (newRow.tempId === rowId) continue;
941
- const held = forms.get(newRow.tempId);
942
- if (held === undefined || deleted.has(newRow.tempId)) continue;
943
- rows.push({
944
- rowId: newRow.tempId,
945
- value: held.form.state.values as TMDataGridRowData,
946
- });
1132
+ // Entry rows the table does not hold: the open ones, and the committed
1133
+ // ones under `newRowsSticky`. A committed row in flow is in `data`
1134
+ // already, so it was listed above.
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 });
947
1148
  }
948
- if (isNew) rows.push({ rowId, value });
949
1149
  return rows;
950
1150
  };
951
1151
 
@@ -953,11 +1153,17 @@ export function createEditEngine(
953
1153
  * Runs `editing.tableValidators` for one commit. `onSubmit` first; its
954
1154
  * failure stands and `onSubmitAsync` is not consulted, mirroring how the
955
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.
956
1161
  */
957
1162
  const runTableValidators = async (
958
1163
  value: TMDataGridRowData,
959
1164
  rowId: string,
960
1165
  isNew: boolean,
1166
+ rows?: ReadonlyArray<{ rowId: string; value: TMDataGridRowData }>,
961
1167
  ): Promise<unknown> => {
962
1168
  const validators = getContext().tableValidators;
963
1169
  if (
@@ -970,7 +1176,7 @@ export function createEditEngine(
970
1176
  value,
971
1177
  rowId,
972
1178
  isNew,
973
- rows: mergedRows(rowId, value, isNew),
1179
+ rows: rows ?? mergedRows({ rowId, value, isNew }),
974
1180
  };
975
1181
  const sync = validators.onSubmit?.(args);
976
1182
  if (isValidationError(sync)) return sync;
@@ -1051,15 +1257,16 @@ export function createEditEngine(
1051
1257
  };
1052
1258
  };
1053
1259
 
1260
+ /** What moved between a row as it was and a row as drafted. */
1054
1261
  const diff = (
1055
- entry: FormEntry,
1262
+ original: TMDataGridRowData,
1056
1263
  values: TMDataGridRowData,
1057
1264
  ): Array<TMDataGridEditChange> => {
1058
1265
  const changes: Array<TMDataGridEditChange> = [];
1059
1266
  for (const column of editableColumns()) {
1060
1267
  const field = getEditFieldName(column);
1061
1268
  if (field === null) continue;
1062
- const previous: unknown = getBy(entry.original, field);
1269
+ const previous: unknown = getBy(original, field);
1063
1270
  const next: unknown = getBy(values, field);
1064
1271
  if (!sameValue(previous, next)) {
1065
1272
  changes.push({ columnId: column.id, field, previous, next });
@@ -1091,7 +1298,7 @@ export function createEditEngine(
1091
1298
  ...kept.map(({ field, message }) => ({ field, message })),
1092
1299
  ];
1093
1300
  return {
1094
- dirtyFields: diff(entry, values).map((change) => change.field),
1301
+ dirtyFields: diff(entry.original, values).map((change) => change.field),
1095
1302
  errorFields: errorMessages.map((error) => error.field),
1096
1303
  errorMessages,
1097
1304
  hasRowError: hasAnyError(state.errors),
@@ -1100,90 +1307,314 @@ export function createEditEngine(
1100
1307
  };
1101
1308
  };
1102
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
+
1103
1496
  const publishRow = (rowId: string) => {
1104
- const entry = forms.get(rowId);
1105
- store.setState((prev) => {
1106
- const rows = { ...prev.rows };
1107
- if (entry === undefined) delete rows[rowId];
1108
- else rows[rowId] = project(entry);
1109
- return { ...prev, rows, openRowIds: [...forms.keys()] };
1110
- });
1497
+ staleRows.add(rowId);
1498
+ publish();
1111
1499
  };
1112
1500
 
1113
1501
  const setActive = (active: TMDataGridEditState["active"]) => {
1114
- store.setState((prev) => ({ ...prev, active }));
1502
+ if (working.active === active) return;
1503
+ working.active = active;
1504
+ touch("active");
1115
1505
  };
1116
1506
 
1117
- const setNewRowCommitted = (tempId: string, committed: boolean) => {
1118
- store.setState((prev) => {
1119
- const target = prev.newRows.find((newRow) => newRow.tempId === tempId);
1120
- if (target === undefined || target.committed === committed) return prev;
1121
- return {
1122
- ...prev,
1123
- newRows: prev.newRows.map((newRow) =>
1124
- newRow.tempId === tempId ? { ...newRow, committed } : newRow,
1125
- ),
1126
- };
1127
- });
1507
+ /**
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.
1514
+ */
1515
+ const snapshotCommitted = (rowId: string) => {
1516
+ const values = committed.get(rowId)?.values;
1517
+ if (values === undefined || working.committedValues.get(rowId) === values) {
1518
+ return;
1519
+ }
1520
+ working.committedValues.set(rowId, values);
1521
+ dirty.add("committedValues");
1522
+ };
1523
+
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();
1128
1533
  };
1129
1534
 
1130
1535
  /**
1131
1536
  * Moves an existing row across the line between form state and the draft
1132
- * store. The values never move - they stay in the row's form; this records
1133
- * which side the row is on, and the entry's 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.
1134
1541
  */
1135
- const setCommitted = (rowId: string, committed: boolean) => {
1136
- const entry = forms.get(rowId);
1137
- if (entry !== undefined) entry.committed = committed;
1138
- store.setState((prev) => {
1139
- const has = prev.committedRowIds.includes(rowId);
1140
- if (has === committed) return prev;
1141
- return {
1142
- ...prev,
1143
- committedRowIds: committed
1144
- ? [...prev.committedRowIds, rowId]
1145
- : prev.committedRowIds.filter((id) => id !== rowId),
1146
- };
1147
- });
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();
1148
1550
  };
1149
1551
 
1150
- 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) => {
1151
1559
  const entry = forms.get(rowId);
1152
1560
  if (entry === undefined) return;
1153
1561
  entry.unsubscribe();
1154
- entry.unmount();
1155
1562
  forms.delete(rowId);
1156
- store.setState((prev) => {
1157
- const rows = { ...prev.rows };
1158
- delete rows[rowId];
1159
- return {
1160
- ...prev,
1161
- rows,
1162
- openRowIds: [...forms.keys()],
1163
- committedRowIds: prev.committedRowIds.filter((id) => id !== rowId),
1164
- newRows: entry.isNew
1165
- ? prev.newRows.filter((newRow) => newRow.tempId !== rowId)
1166
- : prev.newRows,
1167
- active: prev.active?.rowId === rowId ? null : prev.active,
1168
- };
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
+ }
1169
1592
  });
1170
1593
  };
1171
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
+ */
1172
1604
  const createForm = (
1173
1605
  rowId: string,
1174
1606
  original: TMDataGridRowData,
1175
1607
  isNew: boolean,
1608
+ values?: TMDataGridRowData,
1176
1609
  ): FormEntry => {
1177
1610
  const entry: FormEntry = {
1178
1611
  original,
1179
1612
  isNew,
1180
- committed: false,
1181
1613
  lastSubmitOk: false,
1182
1614
  submitErrors: [],
1183
1615
  parkOnly: false,
1184
1616
  pendingCommit: null,
1185
1617
  unsubscribe: () => {},
1186
- unmount: () => {},
1187
1618
  form: new FormApi({
1188
1619
  defaultValues: original,
1189
1620
  // The consumer's vocabulary is Form's own; the cast erases the
@@ -1198,12 +1629,8 @@ export function createEditEngine(
1198
1629
  tempId: rowId,
1199
1630
  value: value as TMDataGridRowData,
1200
1631
  };
1201
- if (draftAddCollector !== null) {
1202
- draftAddCollector.push(addArgs);
1203
- entry.lastSubmitOk = true;
1204
- return;
1205
- }
1206
- // 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.
1207
1634
  if (entry.parkOnly) {
1208
1635
  entry.lastSubmitOk = true;
1209
1636
  return;
@@ -1219,7 +1646,7 @@ export function createEditEngine(
1219
1646
  }
1220
1647
  return;
1221
1648
  }
1222
- const changes = diff(entry, value as TMDataGridRowData);
1649
+ const changes = diff(entry.original, value as TMDataGridRowData);
1223
1650
  const args: TMDataGridEditCommitArgs<TMDataGridRowData> = {
1224
1651
  rowId,
1225
1652
  value: value as TMDataGridRowData,
@@ -1227,12 +1654,8 @@ export function createEditEngine(
1227
1654
  changes,
1228
1655
  source: getContext().editMode,
1229
1656
  };
1230
- if (draftCollector !== null) {
1231
- draftCollector.push(args);
1232
- entry.lastSubmitOk = true;
1233
- return;
1234
- }
1235
- // 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.
1236
1659
  if (entry.parkOnly) {
1237
1660
  entry.lastSubmitOk = true;
1238
1661
  return;
@@ -1250,22 +1673,118 @@ export function createEditEngine(
1250
1673
  },
1251
1674
  }) as TMDataGridRowEditForm,
1252
1675
  };
1253
- 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
+ }
1254
1691
  const subscription = entry.form.store.subscribe(() => publishRow(rowId));
1255
1692
  entry.unsubscribe = () => subscription.unsubscribe();
1256
1693
  forms.set(rowId, entry);
1257
- 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();
1701
+ return entry;
1702
+ };
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);
1258
1725
  return entry;
1259
1726
  };
1260
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
+
1261
1774
  const canEditRow = (row: ErasedRow): boolean => {
1262
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;
1263
1781
  return getContext().isRowEditable?.(row) !== false;
1264
1782
  };
1265
1783
 
1266
1784
  const canEditCell = (row: ErasedRow, column: ErasedColumn): boolean => {
1267
1785
  const context = getContext();
1268
1786
  if (row.getIsGrouped()) return false;
1787
+ if (working.deletedRowIds.has(row.id)) return false;
1269
1788
  if (!isColumnEditable(column)) return false;
1270
1789
  if (!isColumnEditableForRow(column, row)) return false;
1271
1790
  if (context.isRowEditable !== undefined && !context.isRowEditable(row)) {
@@ -1305,6 +1824,8 @@ export function createEditEngine(
1305
1824
 
1306
1825
  const commit = async (rowId: string): Promise<boolean> => {
1307
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.
1308
1829
  if (entry === undefined) return true;
1309
1830
  // Enter and blur race on the same edit; the second caller joins the
1310
1831
  // first's commit instead of submitting the row twice.
@@ -1313,9 +1834,10 @@ export function createEditEngine(
1313
1834
  // Not for a new row: adding an untouched entry is still an add.
1314
1835
  if (
1315
1836
  !entry.isNew &&
1316
- diff(entry, entry.form.state.values as TMDataGridRowData).length === 0
1837
+ diff(entry.original, entry.form.state.values as TMDataGridRowData)
1838
+ .length === 0
1317
1839
  ) {
1318
- drop(rowId);
1840
+ forget(rowId);
1319
1841
  return true;
1320
1842
  }
1321
1843
  // Field errors the *form's* validator planted are cleared before the
@@ -1325,13 +1847,18 @@ export function createEditEngine(
1325
1847
  clearFormSourcedFieldErrors(entry.form);
1326
1848
  entry.lastSubmitOk = false;
1327
1849
  entry.submitErrors = [];
1328
- entry.parkOnly = getContext().draft && !savingDrafts;
1850
+ entry.parkOnly = getContext().draft;
1329
1851
  entry.pendingCommit = (async () => {
1330
1852
  try {
1331
1853
  await entry.form.handleSubmit();
1332
1854
  } finally {
1333
1855
  entry.pendingCommit = null;
1334
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;
1335
1862
  if (!entry.lastSubmitOk) {
1336
1863
  // Snapshot before the caller closes the editor: the field errors go
1337
1864
  // with it, and the row is about to be left carrying them.
@@ -1340,49 +1867,67 @@ export function createEditEngine(
1340
1867
  return false;
1341
1868
  }
1342
1869
  if (entry.parkOnly) {
1343
- // Into the draft store: the row's values stay in its form, and the
1344
- // grid records that they are decided. An entry row renders as a
1345
- // value row, an edited row as its draft - both until `begin` takes
1346
- // the row back out into form state, or `saveDrafts` flushes it.
1347
- entry.committed = true;
1348
- if (entry.isNew) setNewRowCommitted(rowId, true);
1349
- else setCommitted(rowId, true);
1350
- store.setState((prev) =>
1351
- prev.active?.rowId === rowId ? { ...prev, active: null } : prev,
1352
- );
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
+ });
1353
1889
  return true;
1354
1890
  }
1355
- drop(rowId);
1891
+ forget(rowId);
1356
1892
  return true;
1357
1893
  })();
1358
1894
  return entry.pendingCommit;
1359
1895
  };
1360
1896
 
1361
1897
  const beginOn = (rowId: string, columnId: string | null) => {
1362
- // An entry row has no backing row in the table; opening one re-arms its
1363
- // editors - with `editing.draft` on, that is how a parked row is edited
1364
- // again.
1365
- const entryForm = forms.get(rowId);
1366
- if (entryForm?.isNew === true) {
1367
- entryForm.committed = false;
1368
- 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
+ }
1369
1929
  setActive({ rowId, columnId });
1370
- return;
1371
- }
1372
- const row = getRow(rowId);
1373
- if (row === undefined) return;
1374
- if (columnId !== null) {
1375
- const column = getContext().table.getColumn(columnId);
1376
- if (column === undefined || !canEditCell(row, column)) return;
1377
- } else if (getContext().isRowEditable?.(row) === false) {
1378
- return;
1379
- }
1380
- if (!forms.has(rowId)) createForm(rowId, row.original, false);
1381
- // Reopening a committed row takes it back out of the draft store: what
1382
- // the user is now editing is undecided again, and `saveDrafts` must not
1383
- // send it until it is committed afresh.
1384
- else if (forms.get(rowId)?.committed === true) setCommitted(rowId, false);
1385
- setActive({ rowId, columnId });
1930
+ });
1386
1931
  };
1387
1932
 
1388
1933
  const begin: TMDataGridEditApi["begin"] = ({ rowId, columnId }) => {
@@ -1396,15 +1941,14 @@ export function createEditEngine(
1396
1941
  // so a second row opening can neither discard the first nor be refused
1397
1942
  // by it - nothing about one row's form bears on another's |
1398
1943
  //
1399
- // A parked row is not "open": it has had its submit and is waiting for
1400
- // 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
1401
1946
  // entry row is skipped too - it is row-shaped in every mode, and its ✓
1402
1947
  // is the decision, so the sweep must not add a half-typed row.
1403
1948
  if (editMode === "cell") {
1404
- const openElsewhere = [...forms.keys()].find((id) => {
1405
- const other = forms.get(id);
1406
- return id !== rowId && other?.committed !== true && other?.isNew !== true;
1407
- });
1949
+ const openElsewhere = [...forms.keys()].find(
1950
+ (id) => id !== rowId && forms.get(id)?.isNew !== true,
1951
+ );
1408
1952
  if (openElsewhere !== undefined) {
1409
1953
  void commit(openElsewhere).then((ok) => {
1410
1954
  if (ok) beginOn(rowId, columnId);
@@ -1416,7 +1960,7 @@ export function createEditEngine(
1416
1960
  };
1417
1961
 
1418
1962
  const cancel = (rowId: string) => {
1419
- drop(rowId);
1963
+ forget(rowId);
1420
1964
  };
1421
1965
 
1422
1966
  const deactivate = () => {
@@ -1424,26 +1968,34 @@ export function createEditEngine(
1424
1968
  };
1425
1969
 
1426
1970
  const cancelAll = () => {
1427
- for (const rowId of [...forms.keys()]) drop(rowId);
1428
- store.setState((prev) => ({
1429
- ...prev,
1430
- active: null,
1431
- committedRowIds: [],
1432
- deletedRowIds: [],
1433
- }));
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
+ });
1434
1984
  };
1435
1985
 
1436
- 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 => {
1437
1988
  newRowCounter += 1;
1438
- const tempId = `__new__${newRowCounter}`;
1989
+ const tempId = `${NEW_ROW_ID_PREFIX}${newRowCounter}`;
1439
1990
  createForm(tempId, seedNewRow(values), true);
1440
- store.setState((prev) => ({
1441
- ...prev,
1442
- newRows: [...prev.newRows, { tempId, committed: false }],
1443
- }));
1991
+ working.newRows.set(tempId, false);
1992
+ dirty.add("newRows");
1444
1993
  return tempId;
1445
1994
  };
1446
1995
 
1996
+ const addRow = (values?: TMDataGridRowData): string =>
1997
+ heldSync(() => openEntryRow(values));
1998
+
1447
1999
  /** Seeds one entry row's values - `newRowDefaults` under `values`. */
1448
2000
  const seedNewRow = (values?: TMDataGridRowData): TMDataGridRowData => {
1449
2001
  const { newRowDefaults } = getContext();
@@ -1459,56 +2011,74 @@ export function createEditEngine(
1459
2011
  const addRows = async (
1460
2012
  rows: ReadonlyArray<TMDataGridRowData>,
1461
2013
  options?: TMDataGridAddRowsOptions,
1462
- ): Promise<TMDataGridAddRowsResult> => {
1463
- const tempIds: Array<string> = [];
1464
- // One notification for the batch: `createForm` publishes its row as it
1465
- // mounts, so an import of hundreds would otherwise wake every subscriber
1466
- // once per row before the entry block has even been told they exist.
1467
- batch(() => {
1468
- for (const values of rows) {
1469
- newRowCounter += 1;
1470
- const tempId = `__new__${newRowCounter}`;
1471
- createForm(tempId, seedNewRow(values), true);
1472
- 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 };
1473
2022
  }
1474
- store.setState((prev) => ({
1475
- ...prev,
1476
- newRows: [
1477
- ...prev.newRows,
1478
- ...tempIds.map((tempId) => ({ tempId, committed: false })),
1479
- ],
1480
- }));
1481
- });
1482
-
1483
- if (options?.commit !== true) return { committed: [], open: tempIds };
1484
2023
 
1485
- // In order, so a consumer's `onRowAdd` sees the rows as the file had
1486
- // them. A row that fails validation stays open carrying its errors.
1487
- const committed: Array<string> = [];
1488
- const open: Array<string> = [];
1489
- for (const tempId of tempIds) {
1490
- const ok = await commit(tempId);
1491
- (ok ? committed : open).push(tempId);
1492
- }
1493
- return { committed, open };
1494
- };
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
+ });
1495
2040
 
1496
2041
  const deleteRow = (rowId: string) => {
1497
- const entry = forms.get(rowId);
1498
- // Deleting an unsaved entry row is just discarding the entry.
1499
- if (entry?.isNew === true) {
1500
- 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);
1501
2049
  return;
1502
2050
  }
1503
2051
  const context = getContext();
1504
2052
  if (context.draft) {
1505
- // A toggle: the second press unmarks - the mark is a draft too.
1506
- store.setState((prev) => ({
1507
- ...prev,
1508
- deletedRowIds: prev.deletedRowIds.includes(rowId)
1509
- ? prev.deletedRowIds.filter((id) => id !== rowId)
1510
- : [...prev.deletedRowIds, rowId],
1511
- }));
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
+ });
1512
2082
  return;
1513
2083
  }
1514
2084
  const row = getRow(rowId);
@@ -1517,6 +2087,19 @@ export function createEditEngine(
1517
2087
  void context.onRowDelete?.({ rowId, row });
1518
2088
  };
1519
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
+
1520
2103
  const canDeleteRows = (): boolean => {
1521
2104
  const context = getContext();
1522
2105
  if (context.draft) {
@@ -1528,151 +2111,275 @@ export function createEditEngine(
1528
2111
  return context.onRowDelete !== undefined;
1529
2112
  };
1530
2113
 
1531
- /** The pending deletions, reported and cleared by `saveDrafts`. */
1532
- const takeDeletedRowIds = (): Array<string> => {
1533
- const deleted = [...store.state.deletedRowIds];
1534
- if (deleted.length > 0) {
1535
- store.setState((prev) => ({ ...prev, deletedRowIds: [] }));
1536
- }
1537
- return deleted;
1538
- };
1539
-
1540
- /** The draft store's rows: committed forms, in the order they landed. */
1541
- const committedFormIds = (): Array<string> =>
1542
- [...forms.keys()].filter((rowId) => forms.get(rowId)?.committed === true);
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));
1543
2117
 
1544
- const commitAll = async (): Promise<boolean> => {
1545
- // Only the open ones - a row already in the draft store has had its
1546
- // submit and must not be put through a second one here.
1547
- const openIds = [...forms.keys()].filter(
1548
- (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))),
1549
2124
  );
1550
- const results = await Promise.all(openIds.map((rowId) => commit(rowId)));
1551
- return results.every(Boolean);
2125
+ return splitCommitted(openIds, results);
1552
2126
  };
1553
2127
 
1554
2128
  /**
1555
2129
  * The in-flight save. A second call while `onSaveDrafts` awaits would
1556
2130
  * re-collect the same payload and send it again - a double-clicked Save
1557
2131
  * would create every pending entry row twice - so concurrent calls join
1558
- * this promise instead of starting a save of their own.
2132
+ * this promise, and its result, instead of starting a save of their own.
1559
2133
  */
1560
- let saveInFlight: Promise<boolean> | null = null;
2134
+ let saveInFlight: Promise<TMDataGridSaveDraftsResult> | null = null;
1561
2135
 
1562
- const saveDrafts = (): Promise<boolean> => {
2136
+ const saveDrafts = (): Promise<TMDataGridSaveDraftsResult> => {
1563
2137
  if (saveInFlight !== null) return saveInFlight;
1564
- // While this runs, `commit` really commits - `editing.draft` parks
1565
- // otherwise. Single-threaded flag, same idiom as the collectors.
1566
- savingDrafts = true;
1567
2138
  saveInFlight = saveDraftsInner().finally(() => {
1568
- savingDrafts = false;
1569
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
+ }
1570
2146
  });
1571
2147
  return saveInFlight;
1572
2148
  };
1573
2149
 
1574
- const saveDraftsInner = async (): Promise<boolean> => {
1575
- const committedIds = committedFormIds();
1576
- 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
+ });
1577
2181
  // Nothing decided: open rows are not this verb's business, so a grid
1578
2182
  // mid-edit with an empty draft store saves cleanly and stays as it is.
1579
- 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();
1580
2200
 
1581
2201
  if (getContext().onSaveDrafts === undefined) {
1582
2202
  // The default: the per-row loop - edits through `onEditCommit`, entry
1583
- // rows through `onRowAdd`, marked deletions through `onRowDelete`.
1584
- const results = await Promise.all(
1585
- 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
+ ),
1586
2244
  );
1587
- 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) {
1588
2256
  const row = getRow(rowId);
1589
- if (row !== undefined) {
1590
- 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);
1591
2264
  }
1592
2265
  }
1593
- 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();
1594
2277
  }
1595
2278
 
1596
- // One consumer call for the lot. Validation stays Form's, per row: rows
1597
- // that fail keep their forms and markers; the valid ones travel
1598
- // together, and only a resolved save drops them - a rejected save keeps
1599
- // every draft, deletions included.
1600
- const collected: Array<TMDataGridEditCommitArgs<TMDataGridRowData>> = [];
1601
- const added: Array<TMDataGridRowAddArgs<TMDataGridRowData>> = [];
1602
- draftCollector = collected;
1603
- draftAddCollector = added;
1604
- let allValid = true;
1605
- try {
1606
- for (const rowId of committedIds) {
1607
- const entry = forms.get(rowId);
1608
- if (entry === undefined) continue;
1609
- entry.lastSubmitOk = false;
1610
- await entry.form.handleSubmit();
1611
- if (!entry.lastSubmitOk) allValid = false;
1612
- }
1613
- } finally {
1614
- draftCollector = null;
1615
- draftAddCollector = null;
1616
- }
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
+ });
1617
2320
  const deleted = deletedIds;
1618
2321
  if (collected.length > 0 || added.length > 0 || deleted.length > 0) {
1619
- let result: void | TMDataGridSaveDraftsResult;
2322
+ let response: void | TMDataGridSaveDraftsResponse;
1620
2323
  try {
1621
- result = await getContext().onSaveDrafts?.({
2324
+ response = await getContext().onSaveDrafts?.({
1622
2325
  updated: collected,
1623
2326
  created: added,
1624
2327
  deleted,
1625
- // The pre-2.0 names, still filled - see TMDataGridSaveDraftsArgs.
1626
- rows: collected,
1627
- added,
1628
2328
  });
1629
2329
  } catch {
1630
- 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();
1631
2333
  }
1632
2334
 
1633
- // Nothing returned saves the lot. A result names what failed; those
2335
+ // Nothing returned saves the lot. A response names what failed; those
1634
2336
  // keep their drafts, committed, so the next save retries them.
1635
- const outcomes = result ?? {};
1636
- let savedAll = true;
1637
-
1638
- for (const args of collected) {
1639
- if (isSaved(outcomes.updated, args.rowId)) drop(args.rowId);
1640
- else savedAll = false;
1641
- }
1642
- for (const args of added) {
1643
- if (isSaved(outcomes.created, args.tempId)) drop(args.tempId);
1644
- else savedAll = false;
1645
- }
1646
- const savedDeletions = deleted.filter((id) =>
1647
- isSaved(outcomes.deleted, id),
1648
- );
1649
- if (savedDeletions.length > 0) {
1650
- store.setState((prev) => ({
1651
- ...prev,
1652
- deletedRowIds: prev.deletedRowIds.filter(
1653
- (id) => !savedDeletions.includes(id),
1654
- ),
1655
- }));
1656
- }
1657
- if (savedDeletions.length < deleted.length) savedAll = false;
1658
-
1659
- 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
+ });
1660
2365
  }
1661
- return allValid;
1662
- };
1663
-
1664
- /** @deprecated The old one-shot save - `commitAll` then `saveDrafts`. */
1665
- const submitAll = async (): Promise<boolean> => {
1666
- const committedOk = await commitAll();
1667
- const savedOk = await saveDrafts();
1668
- return committedOk && savedOk;
2366
+ return report();
1669
2367
  };
1670
2368
 
1671
2369
  /**
1672
2370
  * The write path behind `clearCell`, `setCellValue` and `setRowValues`:
1673
2371
  * fill the row's own form and commit it, exactly as a ✓ on an open editor
1674
2372
  * would. Uses the form already open on the row when there is one, so a
1675
- * 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.
1676
2383
  *
1677
2384
  * No editor is involved, so `meta.edit.mapValue` does not run - the caller
1678
2385
  * writes the stored value itself. Validation is untouched: this is the same
@@ -1686,12 +2393,24 @@ export function createEditEngine(
1686
2393
  ): Promise<boolean> => {
1687
2394
  const row = getRow(rowId);
1688
2395
  if (row === undefined) return false;
2396
+ // Read-only while marked - see canEditRow.
2397
+ if (working.deletedRowIds.has(rowId)) return false;
1689
2398
  if (writes.length === 0) return true;
1690
- const entry = forms.get(rowId) ?? createForm(rowId, row.original, false);
1691
- for (const { field, value } of writes) {
1692
- 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));
1693
2409
  }
1694
- return commit(rowId);
2410
+ return held(async () => {
2411
+ const entry = reopen(rowId);
2412
+ return entry === undefined ? false : write(entry);
2413
+ });
1695
2414
  };
1696
2415
 
1697
2416
  /** The field a cell writes to, or `null` when that cell takes no edit. */
@@ -1743,12 +2462,76 @@ export function createEditEngine(
1743
2462
  return writeFields(rowId, writes);
1744
2463
  };
1745
2464
 
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);
2470
+
2471
+ const getRows = (): ReadonlyArray<TMDataGridEditRowSnapshot> => {
2472
+ const deleted = working.deletedRowIds;
2473
+ const rows: Array<TMDataGridEditRowSnapshot> = [];
2474
+ const model = getContext().table.getCoreRowModel();
2475
+ for (const row of model.flatRows) {
2476
+ rows.push({
2477
+ rowId: row.id,
2478
+ value: draftValues(row.id) ?? (row.original as TMDataGridRowData),
2479
+ isNew:
2480
+ forms.get(row.id)?.isNew ?? committed.get(row.id)?.isNew ?? false,
2481
+ deleted: deleted.has(row.id),
2482
+ });
2483
+ }
2484
+ // Entry rows the table does not hold - see mergedRows.
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;
2489
+ rows.push({
2490
+ rowId: tempId,
2491
+ value: values,
2492
+ isNew: true,
2493
+ deleted: deleted.has(tempId),
2494
+ });
2495
+ }
2496
+ return rows;
2497
+ };
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
+
1746
2525
  return {
1747
2526
  store,
1748
2527
  get state() {
1749
2528
  return store.state;
1750
2529
  },
2530
+ forgetMissingRows,
2531
+ isRowDeleted: (rowId) => working.deletedRowIds.has(rowId),
1751
2532
  getForm: (rowId) => forms.get(rowId)?.form,
2533
+ getRowValues,
2534
+ getRows,
1752
2535
  canEditCell,
1753
2536
  canEditRow,
1754
2537
  isColumnEditable,
@@ -1759,13 +2542,14 @@ export function createEditEngine(
1759
2542
  cancelAll,
1760
2543
  commitAll,
1761
2544
  saveDrafts,
1762
- submitAll,
1763
2545
  clearCell,
1764
2546
  setCellValue,
1765
2547
  setRowValues,
1766
2548
  addRow,
1767
2549
  addRows,
1768
2550
  deleteRow,
2551
+ deleteRows,
2552
+ restoreRow,
1769
2553
  canDeleteRows,
1770
2554
  };
1771
2555
  }