@svgrid/grid 3.0.4 → 3.0.6

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 (176) hide show
  1. package/README.md +32 -11
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +33 -5
  4. package/dist/SvGrid.controller.svelte.js +397 -58
  5. package/dist/SvGrid.css +200 -2
  6. package/dist/SvGrid.svelte +350 -40
  7. package/dist/SvGrid.types.d.ts +433 -7
  8. package/dist/SvGridDropdown.svelte +20 -0
  9. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  10. package/dist/SvModal.svelte +22 -0
  11. package/dist/SvModal.svelte.d.ts +7 -0
  12. package/dist/a11y/dismissable.d.ts +10 -0
  13. package/dist/a11y/dismissable.js +24 -2
  14. package/dist/build-api.js +92 -37
  15. package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-C-NtjlrJ.js} +155 -146
  16. package/dist/cdn/GridMenus-TssHej3q.js +644 -0
  17. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  18. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  19. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  21. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-BlyCSp0q.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-ClRRA9lW.js} +1 -1
  23. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-B4EV8U6t.js} +4 -4
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DsmwTyZF.js} +4 -4
  25. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-cHloHobN.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-vOl7C0Za.js} +4 -4
  27. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-AcqF-Ll5.js} +5 -5
  28. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-biMtSBPA.js} +5 -5
  29. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-Dx_GmZuL.js} +2 -2
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-I3rDHXAq.js} +2 -2
  31. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  32. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  33. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  34. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  35. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-DVVKOSI9.js} +1 -1
  36. package/dist/cdn/{chart-Dz7SqMXH.js → chart-DEZxwC0i.js} +1 -1
  37. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-D12XcTZJ.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DguS7gC5.js} +1 -1
  39. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-CMAXBGY-.js} +1 -1
  40. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-BkEp61Gj.js} +1 -1
  41. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  42. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-YzdwBww2.js} +1 -1
  43. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  44. package/dist/cdn/server-block-cache-DIqb3VZn.js +241 -0
  45. package/dist/cdn/{src-V1uu8iE9.js → src-CgoeSM8k.js} +5339 -4753
  46. package/dist/cdn/{src-BKhZ6eXd.js → src-DfwxB1TH.js} +9817 -9231
  47. package/dist/cdn/svgrid.js +28 -27
  48. package/dist/cdn/svgrid.svelte-external.js +28 -27
  49. package/dist/cdn/validate-AZoD8BoR.js +80 -0
  50. package/dist/cell-formatting.js +18 -1
  51. package/dist/cell-render.js +25 -6
  52. package/dist/clipboard.d.ts +2 -2
  53. package/dist/clipboard.js +72 -23
  54. package/dist/columns.js +7 -2
  55. package/dist/conditional-formatting.d.ts +2 -0
  56. package/dist/conditional-formatting.js +2 -0
  57. package/dist/core.d.ts +22 -6
  58. package/dist/core.js +401 -38
  59. package/dist/editing.js +34 -0
  60. package/dist/gantt-view.svelte.d.ts +24 -0
  61. package/dist/gantt-view.svelte.js +13 -0
  62. package/dist/grid-icons.d.ts +2 -2
  63. package/dist/grid-icons.js +2 -2
  64. package/dist/grid-messages.d.ts +10 -0
  65. package/dist/grid-messages.js +9 -0
  66. package/dist/headless.d.ts +5 -0
  67. package/dist/headless.js +12 -0
  68. package/dist/history.d.ts +21 -0
  69. package/dist/history.js +34 -4
  70. package/dist/index.d.ts +5 -4
  71. package/dist/index.js +3 -3
  72. package/dist/keyboard-handlers.js +24 -1
  73. package/dist/keyboard.js +4 -0
  74. package/dist/row-drag-touch.d.ts +1 -1
  75. package/dist/row-drag.js +35 -1
  76. package/dist/row-model.d.ts +139 -0
  77. package/dist/row-model.js +16 -0
  78. package/dist/row-resize.js +22 -1
  79. package/dist/selection.d.ts +2 -1
  80. package/dist/selection.js +31 -3
  81. package/dist/server-block-cache.d.ts +219 -0
  82. package/dist/server-block-cache.js +539 -0
  83. package/dist/server-data-source.d.ts +274 -8
  84. package/dist/server-data-source.js +229 -20
  85. package/dist/server.d.ts +13 -0
  86. package/dist/server.js +13 -0
  87. package/dist/sparkline.js +7 -5
  88. package/dist/svgrid-wrapper.types.d.ts +33 -0
  89. package/dist/validate.d.ts +4 -0
  90. package/dist/validate.js +29 -3
  91. package/package.json +11 -1
  92. package/src/GridMenus.svelte +34 -4
  93. package/src/SvGrid.controller.svelte.ts +397 -61
  94. package/src/SvGrid.css +200 -2
  95. package/src/SvGrid.svelte +350 -40
  96. package/src/SvGrid.types.ts +453 -7
  97. package/src/SvGridDropdown.svelte +20 -0
  98. package/src/SvModal.svelte +22 -0
  99. package/src/SvModal.test.ts +30 -1
  100. package/src/a11y/dismissable.test.ts +10 -1
  101. package/src/a11y/dismissable.ts +22 -2
  102. package/src/build-api.ts +96 -31
  103. package/src/cell-formatting.test.ts +17 -0
  104. package/src/cell-formatting.ts +18 -1
  105. package/src/cell-render.test.ts +24 -2
  106. package/src/cell-render.ts +24 -8
  107. package/src/clipboard.test.ts +107 -0
  108. package/src/clipboard.ts +62 -23
  109. package/src/columns.test.ts +4 -4
  110. package/src/columns.ts +7 -2
  111. package/src/conditional-formatting.test.ts +8 -0
  112. package/src/conditional-formatting.ts +3 -0
  113. package/src/core.rowmodel-cache.test.ts +51 -0
  114. package/src/core.tick-repair.test.ts +287 -0
  115. package/src/core.ts +407 -38
  116. package/src/editing.test.ts +52 -0
  117. package/src/editing.ts +33 -0
  118. package/src/gantt-stub.test.svelte +38 -0
  119. package/src/gantt-view.svelte.ts +35 -0
  120. package/src/grid-icons.ts +2 -2
  121. package/src/grid-messages.test.ts +7 -0
  122. package/src/grid-messages.ts +22 -0
  123. package/src/headless.ts +24 -0
  124. package/src/history.test.ts +49 -1
  125. package/src/history.ts +48 -2
  126. package/src/icon-seam.test.ts +2 -4
  127. package/src/index.ts +38 -10
  128. package/src/keyboard-handlers.coverage.test.ts +65 -0
  129. package/src/keyboard-handlers.ts +26 -1
  130. package/src/keyboard.ts +3 -0
  131. package/src/row-drag-touch.ts +2 -2
  132. package/src/row-drag.test.ts +51 -0
  133. package/src/row-drag.ts +35 -2
  134. package/src/row-model.ts +152 -0
  135. package/src/row-resize.ts +24 -1
  136. package/src/selection.test.ts +3 -0
  137. package/src/selection.ts +33 -3
  138. package/src/server-block-cache.test.ts +672 -0
  139. package/src/server-block-cache.ts +690 -0
  140. package/src/server-data-source.infinite.test.ts +405 -0
  141. package/src/server-data-source.test.ts +19 -0
  142. package/src/server-data-source.ts +496 -29
  143. package/src/server.ts +49 -0
  144. package/src/sparkline.test.ts +7 -0
  145. package/src/sparkline.ts +6 -4
  146. package/src/svgrid-wrapper.types.ts +32 -0
  147. package/src/svgrid.api.test.ts +21 -0
  148. package/src/svgrid.charting.test.ts +12 -4
  149. package/src/svgrid.context-menu.test.ts +12 -0
  150. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  151. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  152. package/src/svgrid.gantt-seam.test.ts +199 -0
  153. package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
  154. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  155. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  156. package/src/svgrid.row-model-seam.svelte.test.ts +305 -0
  157. package/src/svgrid.row-pinning.test.ts +15 -1
  158. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  159. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  160. package/src/svgrid.upsell-license.test.ts +8 -5
  161. package/src/test-setup.ts +12 -0
  162. package/src/transaction.test.ts +58 -0
  163. package/src/validate.test.ts +29 -0
  164. package/src/validate.ts +34 -3
  165. package/dist/SvGroupCell.svelte +0 -141
  166. package/dist/SvGroupCell.svelte.d.ts +0 -49
  167. package/dist/SvRowGroupPanel.svelte +0 -186
  168. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  169. package/dist/cdn/GridMenus-7kbpnnBW.js +0 -635
  170. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  171. package/dist/server-group-model.d.ts +0 -98
  172. package/dist/server-group-model.js +0 -263
  173. package/src/SvGroupCell.svelte +0 -141
  174. package/src/SvRowGroupPanel.svelte +0 -186
  175. package/src/server-group-model.test.ts +0 -294
  176. package/src/server-group-model.ts +0 -370
package/src/core.ts CHANGED
@@ -393,8 +393,9 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
393
393
  * - `false` → invalid, no message
394
394
  * - a non-empty `string` → invalid, string is the tooltip
395
395
  *
396
- * The value keeps rendering as-is (the grid does NOT roll it back); pair
397
- * with `onCellValueChange` if you also want to reject the commit.
396
+ * On its own `validate` flags: the value keeps rendering as-is and an
397
+ * edit that fails it still lands. Set `rejectInvalid` to refuse such an
398
+ * edit instead.
398
399
  */
399
400
  validate?: (params: {
400
401
  value: unknown
@@ -402,6 +403,16 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
402
403
  rowIndex: number
403
404
  column: Column<TData>
404
405
  }) => string | boolean | null | undefined
406
+ /**
407
+ * Refuse an edit that `validate` flags. When true, a committed value that
408
+ * makes `validate` return `false` or a message is not written: the cell
409
+ * keeps its old value, no history step is recorded and
410
+ * `onCellValueChange` does not fire. The value is checked after
411
+ * `valueParser` has run, so the rule sees what would be stored. Applies to
412
+ * the inline editor, a commit to a selection and a full-row edit; paste
413
+ * does not go through the editor and is not checked.
414
+ */
415
+ rejectInvalid?: boolean
405
416
  /**
406
417
  * Gate editing per column or per cell.
407
418
  *
@@ -786,12 +797,29 @@ type BaseRowCtx<TData extends RowData> = {
786
797
  const ROW_CTX = Symbol('svgrid.row.ctx')
787
798
  const ROW_VALUES = Symbol('svgrid.row.values')
788
799
  const ROW_CELLS = Symbol('svgrid.row.cells')
800
+ // Where the filtered stage last put this row: the generation of that full
801
+ // filter and the row's index in its output. Lets a tick swap a replaced row
802
+ // into place without re-filtering everything (see createFilteredRowModel).
803
+ const ROW_FILTER_GEN = Symbol('svgrid.row.filterGen')
804
+ const ROW_FILTER_POS = Symbol('svgrid.row.filterPos')
805
+ // Stamped on the rows a sort repair drops, so the merge skips them with one
806
+ // property read per row instead of a Set lookup (3 ms per 100k rows).
807
+ const ROW_SORT_DROPPED = Symbol('svgrid.row.sortDropped')
808
+ // The key(s) the last full sort computed for a row: the key itself for a
809
+ // one-clause sort, an array for several. Read by the repair path so a tick
810
+ // never recomputes a kept row's key.
811
+ const ROW_SORT_KEY = Symbol('svgrid.row.sortKey')
812
+ let sortDropGeneration = 0
789
813
 
790
814
  /** A base row's private fields, on top of the public {@link Row} surface. */
791
815
  type BaseRowState<TData extends RowData> = Row<TData> & {
792
816
  [ROW_CTX]: BaseRowCtx<TData>
793
817
  [ROW_VALUES]: Array<unknown> | null
794
818
  [ROW_CELLS]: Array<Cell<TData>> | null
819
+ [ROW_FILTER_GEN]: number
820
+ [ROW_FILTER_POS]: number
821
+ [ROW_SORT_DROPPED]: number
822
+ [ROW_SORT_KEY]: unknown
795
823
  }
796
824
 
797
825
  /**
@@ -912,9 +940,26 @@ export function createCoreRowModel<TData extends RowData>(): RowModelFactory<TDa
912
940
  * `columnFilteringFeature`; without it there are no filters to apply.
913
941
  */
914
942
  export function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {
943
+ /**
944
+ * The last full filter, so a tick can repair it. When `getRowModel`
945
+ * reports which rows were replaced (`replacedRowsOf`) and the filter state
946
+ * and columns are the same objects as last time, each replacement is tested
947
+ * on its own: a row that stays in keeps its slot, a row that stays out
948
+ * stays out, and a row whose membership flips sends the whole thing back
949
+ * through the full pass, because an insertion has to land in data order.
950
+ * Positions come from stamps on the rows (ROW_FILTER_GEN / ROW_FILTER_POS)
951
+ * written by the full pass; no map is built.
952
+ */
953
+ let cache: { input: Array<Row<TData>>; output: Array<Row<TData>>; filters: ColumnFiltersState; columns: Array<Column<TData>>; gen: number } | null = null
954
+ let generation = 0
955
+
915
956
  return ({ table, rows }) => {
916
957
  const filters: ColumnFiltersState = table.getState().columnFilters ?? []
917
- if (!filters.length) return rows
958
+ if (!filters.length) {
959
+ cache = null
960
+ return rows
961
+ }
962
+ const columns = table.getAllColumns()
918
963
 
919
964
  // Resolve each filter's match function once, outside the row loop.
920
965
  const compiled = filters.map((filter) => ({
@@ -922,8 +967,7 @@ export function createFilteredRowModel<TData extends RowData>(): RowModelFactory
922
967
  value: filter.value,
923
968
  fn: filter.fn ? filterFns[filter.fn] : filterFns.includesString,
924
969
  }))
925
-
926
- return rows.filter((row) => {
970
+ const passes = (row: Row<TData>) => {
927
971
  for (let i = 0; i < compiled.length; i++) {
928
972
  const filter = compiled[i]!
929
973
  // `getCellValueByColumnId` rather than `getAllCells().find(...)`.
@@ -935,7 +979,48 @@ export function createFilteredRowModel<TData extends RowData>(): RowModelFactory
935
979
  if (!filter.fn(row.getCellValueByColumnId(filter.id), filter.value as any)) return false
936
980
  }
937
981
  return true
938
- })
982
+ }
983
+
984
+ if (cache && cache.filters === filters && cache.columns === columns) {
985
+ if (cache.input === rows) return cache.output
986
+ const replaced = replacedRowsOf(table)
987
+ if (replaced && rows.length === cache.input.length) {
988
+ let out: Array<Row<TData>> | null = null
989
+ let ok = true
990
+ for (let i = 0; i < rows.length && ok; i++) {
991
+ const prev = cache.input[i]! as BaseRowState<TData>
992
+ const next = rows[i]! as BaseRowState<TData>
993
+ if (next === prev) continue
994
+ if (replaced.get(prev) !== next) { ok = false; break }
995
+ const wasIn = prev[ROW_FILTER_GEN] === cache.gen
996
+ const isIn = passes(next)
997
+ if (wasIn !== isIn) { ok = false; break }
998
+ if (!isIn) continue
999
+ out ??= cache.output.slice()
1000
+ const pos = prev[ROW_FILTER_POS]
1001
+ out[pos] = next
1002
+ next[ROW_FILTER_GEN] = cache.gen
1003
+ next[ROW_FILTER_POS] = pos
1004
+ }
1005
+ if (ok) {
1006
+ const output = out ?? cache.output
1007
+ cache = { ...cache, input: rows, output }
1008
+ return output
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ const gen = ++generation
1014
+ const output: Array<Row<TData>> = []
1015
+ for (let i = 0; i < rows.length; i++) {
1016
+ const row = rows[i]! as BaseRowState<TData>
1017
+ if (!passes(row)) continue
1018
+ row[ROW_FILTER_GEN] = gen
1019
+ row[ROW_FILTER_POS] = output.length
1020
+ output.push(row)
1021
+ }
1022
+ cache = { input: rows, output, filters, columns, gen }
1023
+ return output
939
1024
  }
940
1025
  }
941
1026
  /**
@@ -1225,6 +1310,15 @@ export function flattenTreeData<T extends RowData>(
1225
1310
  export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
1226
1311
  return ({ table, rows }) => {
1227
1312
  const expanded: ExpandedState = table.getState().expanded ?? {}
1313
+ // Nothing expanded means every collapsed row hides its children and the
1314
+ // output is the input, element for element. Returning it as is keeps the
1315
+ // array identity the stages downstream and the render window key on, and
1316
+ // skips a 100k-element copy per data change on a flat grid.
1317
+ let anyExpanded = false
1318
+ for (const id in expanded) {
1319
+ if (expanded[id]) { anyExpanded = true; break }
1320
+ }
1321
+ if (!anyExpanded) return rows
1228
1322
  const flattened: Array<Row<TData>> = []
1229
1323
  const visit = (row: Row<TData>) => {
1230
1324
  flattened.push(row)
@@ -1241,13 +1335,76 @@ export function createExpandedRowModel<TData extends RowData>(): RowModelFactory
1241
1335
  * override the built-in {@link sortFns} - useful for locale-aware or
1242
1336
  * domain-specific ordering.
1243
1337
  */
1338
+ /**
1339
+ * Which rows the last `getRowModel` rebuild replaced, old row -> new row, for
1340
+ * the duration of the pipeline run that follows it.
1341
+ *
1342
+ * A live feed replaces a few hundred row objects in a hundred-thousand-row
1343
+ * array and hands the grid the new array. The base rows are rebuilt reusing
1344
+ * every row whose data object is the same (see `getRowModel`), so the rebuild
1345
+ * knows exactly which rows are new. The filtered and sorted stages read that
1346
+ * here and repair their previous output instead of recomputing it, when the
1347
+ * replacements are few and nothing else changed. Cleared after the run; a
1348
+ * stage that reads it outside a run sees null and does the full work.
1349
+ *
1350
+ * Keyed on the grid object in a WeakMap rather than carried on the public
1351
+ * `SvGrid` type, so the stage signature stays `({ table, rows }) => rows`.
1352
+ */
1353
+ const replacedRowsByGrid = new WeakMap<object, ReadonlyMap<Row<any>, Row<any>> | null>()
1354
+
1355
+ /** The rows `getRowModel` replaced in the rebuild this pipeline run follows, or null. */
1356
+ export function replacedRowsOf(table: SvGrid<any>): ReadonlyMap<Row<any>, Row<any>> | null {
1357
+ return replacedRowsByGrid.get(table) ?? null
1358
+ }
1359
+
1360
+ /**
1361
+ * The sorting stage of the row pipeline: orders the rows by the grid's
1362
+ * `sorting` state with the built-in comparators (or the `localSortFns` map
1363
+ * handed in), stable, equal keys keeping their data order. A tick that
1364
+ * replaces a few rows out of many is repaired incrementally rather than
1365
+ * re-sorted (see the note inside). Registered by `rowSortingFeature`; an app
1366
+ * only calls this to install its own comparator set.
1367
+ */
1244
1368
  export function createSortedRowModel<TData extends RowData>(
1245
1369
  localSortFns: typeof sortFns = sortFns,
1246
1370
  ): RowModelFactory<TData> {
1247
- return function sortedRowModelStage({ table, rows }) {
1248
- const sorting = table.getState().sorting ?? []
1249
- if (!sorting.length) return rows
1371
+ /**
1372
+ * What the last full sort produced, kept so a tick can repair it.
1373
+ *
1374
+ * A live feed replaces a few hundred row objects out of a hundred thousand
1375
+ * and hands the grid a new array. Re-sorting all of it costs the same as
1376
+ * the first sort did (115-149 ms in the browser for 100k rows, see
1377
+ * docs/help/benchmarks.md); the order it would produce differs from the
1378
+ * previous one only where the replaced rows land. So the previous output
1379
+ * is kept, with its sort keys aligned to it, and when `getRowModel` says
1380
+ * which rows were replaced (see `replacedRowsOf`) the stage drops those,
1381
+ * sorts the replacements among themselves and merges them back in one
1382
+ * linear pass: O(n + k log k) instead of O(n log n) key builds and
1383
+ * comparisons.
1384
+ *
1385
+ * The repair is only attempted when everything else is the same object as
1386
+ * last time - the sorting state, the columns, and every input row except
1387
+ * the replaced ones - and it produces exactly what the full sort would:
1388
+ * equal keys fall back to the data index, which is the order a stable sort
1389
+ * over data-ordered input keeps.
1390
+ */
1391
+ let cache: {
1392
+ input: Array<Row<TData>>
1393
+ output: Array<Row<TData>>
1394
+ sorting: SortingState
1395
+ columns: Array<Column<TData>>
1396
+ clauses: Array<SortClause>
1397
+ } | null = null
1398
+
1399
+ type SortClause = {
1400
+ desc: boolean
1401
+ compare: (a: any, b: any) => number
1402
+ /** The key for one row, or `MISSING_KEY` when it cannot be produced incrementally. */
1403
+ keyOf: (row: Row<TData>) => any
1404
+ }
1405
+ const MISSING_KEY = Symbol('svgrid.sort.missing-key')
1250
1406
 
1407
+ function fullSort(rows: Array<Row<TData>>, sorting: SortingState, allColumns: Array<Column<TData>>) {
1251
1408
  // Resolve every clause ONCE, before sorting.
1252
1409
  //
1253
1410
  // This used to live inside the comparator, so `getAllColumns().find(...)`
@@ -1255,11 +1412,11 @@ export function createSortedRowModel<TData extends RowData>(
1255
1412
  // 1,528,947 array scans, and a three-clause sort made 3,933,751 (measured;
1256
1413
  // `pnpm bench --case=sort-1col`). The comparator is called O(n log n)
1257
1414
  // times, so anything inside it that is not O(1) sets the cost of the sort.
1258
- const allColumns = table.getAllColumns()
1259
1415
  const clauses: Array<{
1260
1416
  keys: Array<any>
1261
1417
  desc: boolean
1262
1418
  compare: (a: any, b: any) => number
1419
+ keyOf: (row: Row<TData>) => any
1263
1420
  }> = []
1264
1421
 
1265
1422
  for (const clause of sorting) {
@@ -1288,14 +1445,15 @@ export function createSortedRowModel<TData extends RowData>(
1288
1445
  const n = rows.length
1289
1446
  let keys: Array<any> = new Array(n)
1290
1447
  let compare: (a: any, b: any) => number
1448
+ let keyOf: (row: Row<TData>) => any
1291
1449
 
1292
1450
  if (comparator === sortFns.number) {
1293
- for (let i = 0; i < n; i++) keys[i] = Number(rows[i]!.getCellValueByColumnId(columnId) ?? 0)
1451
+ keyOf = (row) => Number(row.getCellValueByColumnId(columnId) ?? 0)
1452
+ for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
1294
1453
  compare = compareNumericKeys
1295
1454
  } else if (comparator === sortFns.date) {
1296
- for (let i = 0; i < n; i++) {
1297
- keys[i] = new Date(rows[i]!.getCellValueByColumnId(columnId) as any).getTime()
1298
- }
1455
+ keyOf = (row) => new Date(row.getCellValueByColumnId(columnId) as any).getTime()
1456
+ for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
1299
1457
  compare = compareNumericKeys
1300
1458
  } else if (comparator === sortFns.auto) {
1301
1459
  const strings: string[] = new Array(n)
@@ -1354,19 +1512,24 @@ export function createSortedRowModel<TData extends RowData>(
1354
1512
  for (let i = 0; i < ordered.length; i++) rankOf.set(ordered[i]!, i)
1355
1513
  for (let i = 0; i < n; i++) keys[i] = rankOf.get(strings[i]!)!
1356
1514
  compare = compareNumericKeys
1515
+ // A replacement whose text is not among the ranked values has no
1516
+ // rank; the repair path treats that as "sort everything again".
1517
+ keyOf = (row) => rankOf.get(String(row.getCellValueByColumnId(columnId))) ?? MISSING_KEY
1357
1518
  } else {
1358
1519
  keys = strings
1359
1520
  compare = compareCollatedKeys
1521
+ keyOf = (row) => String(row.getCellValueByColumnId(columnId))
1360
1522
  }
1361
1523
  } else {
1362
- for (let i = 0; i < n; i++) keys[i] = rows[i]!.getCellValueByColumnId(columnId)
1524
+ keyOf = (row) => row.getCellValueByColumnId(columnId)
1525
+ for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
1363
1526
  compare = comparator
1364
1527
  }
1365
1528
 
1366
- clauses.push({ keys, desc: clause.desc, compare })
1529
+ clauses.push({ keys, desc: clause.desc, compare, keyOf })
1367
1530
  }
1368
1531
 
1369
- if (!clauses.length) return rows
1532
+ if (!clauses.length) return null
1370
1533
 
1371
1534
  // Sort an index array, then materialise. `Array.prototype.sort` is stable,
1372
1535
  // so equal keys keep their original relative order exactly as the previous
@@ -1415,7 +1578,159 @@ export function createSortedRowModel<TData extends RowData>(
1415
1578
 
1416
1579
  const sorted = new Array<Row<TData>>(rows.length)
1417
1580
  for (let i = 0; i < order.length; i++) sorted[i] = rows[order[i]!]!
1418
- return sorted
1581
+ // Each row keeps its key(s), so a later tick can place a replacement
1582
+ // among the kept rows without reading any of them again. One property
1583
+ // write per row; an array per row only when there are several clauses.
1584
+ if (clauses.length === 1) {
1585
+ const keys = clauses[0]!.keys
1586
+ for (let i = 0; i < rows.length; i++) (rows[i] as any)[ROW_SORT_KEY] = keys[i]
1587
+ } else {
1588
+ for (let i = 0; i < rows.length; i++) {
1589
+ const per = new Array(clauses.length)
1590
+ for (let k = 0; k < clauses.length; k++) per[k] = clauses[k]!.keys[i]
1591
+ ;(rows[i] as any)[ROW_SORT_KEY] = per
1592
+ }
1593
+ }
1594
+ return { sorted, clauses: clauses.map(({ desc, compare, keyOf }) => ({ desc, compare, keyOf })) }
1595
+ }
1596
+
1597
+ /**
1598
+ * The previous output with the replaced rows swapped for their
1599
+ * replacements, in sorted position. Null when the input is not the cached
1600
+ * input plus replacements, or a replacement has no incremental key.
1601
+ *
1602
+ * Every row carries the key(s) the last full sort computed for it
1603
+ * (ROW_SORT_KEY: the key itself for one clause, an array for several), so
1604
+ * the merge moves row references and nothing else. At 100k rows the loop
1605
+ * that also copied an aligned key array cost 3.7 ms; this one is the
1606
+ * reference copy alone.
1607
+ */
1608
+ function repair(rows: Array<Row<TData>>, replaced: ReadonlyMap<Row<any>, Row<any>>) {
1609
+ const c = cache!
1610
+ if (rows.length !== c.input.length) return null
1611
+ // Which rows of THIS stage's input changed. The map may name rows a
1612
+ // stage upstream filtered out; only the ones that reach here matter.
1613
+ const dropGen = ++sortDropGeneration
1614
+ const added: Array<Row<TData>> = []
1615
+ for (let i = 0; i < rows.length; i++) {
1616
+ const prev = c.input[i]!
1617
+ const next = rows[i]!
1618
+ if (next === prev) continue
1619
+ if (replaced.get(prev) !== next) return null
1620
+ ;(prev as any)[ROW_SORT_DROPPED] = dropGen
1621
+ added.push(next)
1622
+ }
1623
+ if (!added.length) return c.output
1624
+
1625
+ const clauses = c.clauses
1626
+ const single = clauses.length === 1
1627
+ for (let j = 0; j < added.length; j++) {
1628
+ const row = added[j]! as any
1629
+ if (single) {
1630
+ const key = clauses[0]!.keyOf(row)
1631
+ if (key === MISSING_KEY) return null
1632
+ row[ROW_SORT_KEY] = key
1633
+ } else {
1634
+ const keys = new Array(clauses.length)
1635
+ for (let k = 0; k < clauses.length; k++) {
1636
+ const key = clauses[k]!.keyOf(row)
1637
+ if (key === MISSING_KEY) return null
1638
+ keys[k] = key
1639
+ }
1640
+ row[ROW_SORT_KEY] = keys
1641
+ }
1642
+ }
1643
+
1644
+ // Keys first, then the data index: the tie-break a stable sort over
1645
+ // data-ordered input applies, so the merge lands where a full sort would.
1646
+ const prevOut = c.output
1647
+ const compareRows = single
1648
+ ? (() => {
1649
+ const { compare, desc } = clauses[0]!
1650
+ return (a: Row<TData>, b: Row<TData>) => {
1651
+ const r = compare((a as any)[ROW_SORT_KEY], (b as any)[ROW_SORT_KEY])
1652
+ if (r !== 0) return desc ? -r : r
1653
+ return a.index - b.index
1654
+ }
1655
+ })()
1656
+ : (a: Row<TData>, b: Row<TData>) => {
1657
+ const ka = (a as any)[ROW_SORT_KEY]
1658
+ const kb = (b as any)[ROW_SORT_KEY]
1659
+ for (let k = 0; k < clauses.length; k++) {
1660
+ const clause = clauses[k]!
1661
+ const r = clause.compare(ka[k], kb[k])
1662
+ if (r !== 0) return clause.desc ? -r : r
1663
+ }
1664
+ return a.index - b.index
1665
+ }
1666
+
1667
+ // Sort the replacements among themselves (k log k)...
1668
+ added.sort(compareRows)
1669
+
1670
+ // ...find where each one goes in the previous output by binary search
1671
+ // (k log n comparisons, against the dropped rows too - they are skipped
1672
+ // below, which does not move an insertion point)...
1673
+ const n = prevOut.length
1674
+ const insertAt = new Array<number>(added.length)
1675
+ let lo = 0
1676
+ for (let j = 0; j < added.length; j++) {
1677
+ // Monotone: the replacements are sorted, so each search starts where
1678
+ // the previous one landed.
1679
+ const row = added[j]!
1680
+ let hi = n
1681
+ while (lo < hi) {
1682
+ const mid = (lo + hi) >>> 1
1683
+ if (compareRows(row, prevOut[mid]!) < 0) hi = mid
1684
+ else lo = mid + 1
1685
+ }
1686
+ insertAt[j] = lo
1687
+ }
1688
+
1689
+ // ...then one linear pass that only moves references.
1690
+ const out = new Array<Row<TData>>(rows.length)
1691
+ let w = 0
1692
+ let j = 0
1693
+ let nextInsert = insertAt[0] ?? -1
1694
+ for (let i = 0; i < n; i++) {
1695
+ while (nextInsert === i) {
1696
+ out[w++] = added[j++]!
1697
+ nextInsert = j < added.length ? insertAt[j]! : -1
1698
+ }
1699
+ const prev = prevOut[i]!
1700
+ if ((prev as any)[ROW_SORT_DROPPED] === dropGen) continue
1701
+ out[w++] = prev
1702
+ }
1703
+ while (j < added.length) out[w++] = added[j++]!
1704
+ return out
1705
+ }
1706
+
1707
+ return function sortedRowModelStage({ table, rows }) {
1708
+ const sorting = table.getState().sorting ?? []
1709
+ if (!sorting.length) {
1710
+ cache = null
1711
+ return rows
1712
+ }
1713
+ const columns = table.getAllColumns()
1714
+
1715
+ if (cache && cache.sorting === sorting && cache.columns === columns) {
1716
+ if (cache.input === rows) return cache.output
1717
+ const replaced = replacedRowsOf(table)
1718
+ if (replaced) {
1719
+ const repaired = repair(rows, replaced)
1720
+ if (repaired) {
1721
+ cache = { ...cache, input: rows, output: repaired }
1722
+ return repaired
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ const full = fullSort(rows, sorting, columns)
1728
+ if (!full) {
1729
+ cache = null
1730
+ return rows
1731
+ }
1732
+ cache = { input: rows, output: full.sorted, sorting, columns, clauses: full.clauses }
1733
+ return full.sorted
1419
1734
  }
1420
1735
  }
1421
1736
 
@@ -1545,6 +1860,9 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1545
1860
  let cachedBaseRowsInput: ReadonlyArray<TData> | null = null
1546
1861
  let cachedBaseRowsColumns: Array<Column<TData>> | null = null
1547
1862
  let cachedBaseRows: Array<Row<TData>> = []
1863
+ /** Old row -> new row for the rebuild the next pipeline run follows; see `replacedRowsOf`. */
1864
+ let replacedRows: Map<Row<TData>, Row<TData>> | null = null
1865
+ let cachedRowCtx: BaseRowCtx<TData> | null = null
1548
1866
  let cachedRowModel: RowModel<TData> | null = null
1549
1867
  let cachedRowModelBaseRows: Array<Row<TData>> | null = null
1550
1868
  let cachedPipeline = options._rowModels
@@ -1753,26 +2071,47 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1753
2071
  },
1754
2072
  getRowModel() {
1755
2073
  const columns = grid.getAllColumns()
1756
- if (cachedBaseRowsInput !== options.data || cachedBaseRowsColumns !== columns) {
1757
- cachedBaseRowsInput = options.data
2074
+ // Read once. `options.data` is a getter when <SvGrid> drives the engine
2075
+ // (a $state.raw signal), and the rebuild below touched it twice per row:
2076
+ // 200k tracked reads per 100k-row tick, 11 ms of a 65 ms tick.
2077
+ const data = options.data
2078
+ if (cachedBaseRowsInput !== data || cachedBaseRowsColumns !== columns) {
2079
+ // Same columns as last time: the shared context still describes them,
2080
+ // and a row whose data object sits at the same index can keep its
2081
+ // row object. A row model that streams blocks hands the grid a new
2082
+ // array of 60k entries per block where 100 changed; building 60k
2083
+ // fresh row objects each time was most of the cost of a block
2084
+ // landing. The reused row drops its memoised values and cells, since
2085
+ // an app may have changed the object in place before passing a new
2086
+ // array - that is the case the old rebuild covered by accident.
2087
+ const previous = cachedBaseRowsColumns === columns && cachedRowCtx ? cachedBaseRows : null
2088
+ // Same length as last time: a replacement, not an add or remove, so
2089
+ // each new row stands in for the old row at its index and the stages
2090
+ // can repair rather than recompute. Anything else is structural.
2091
+ replacedRows = previous && previous.length === data.length ? new Map() : null
2092
+ cachedBaseRowsInput = data
1758
2093
  cachedBaseRowsColumns = columns
1759
2094
  // O(1) column-id → index lookup so getCellValueByColumnId doesn't do
1760
2095
  // a linear `findIndex` on every cell read (was O(rows × cells × cols)).
1761
- const columnIndexById = new Map<string, number>()
1762
- for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
1763
- const columnCount = columns.length
1764
-
1765
- // One shared context for every row in this table, so a row carries a
1766
- // pointer rather than a closure scope. See BASE_ROW_METHODS.
1767
- const rowCtx: BaseRowCtx<TData> = {
1768
- grid: grid as SvGrid<TData>,
1769
- store,
1770
- columns,
1771
- columnCount,
1772
- columnIndexById,
2096
+ let rowCtx: BaseRowCtx<TData>
2097
+ if (previous && cachedRowCtx) {
2098
+ rowCtx = cachedRowCtx
2099
+ } else {
2100
+ const columnIndexById = new Map<string, number>()
2101
+ for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
2102
+ // One shared context for every row in this table, so a row carries a
2103
+ // pointer rather than a closure scope. See BASE_ROW_METHODS.
2104
+ rowCtx = {
2105
+ grid: grid as SvGrid<TData>,
2106
+ store,
2107
+ columns,
2108
+ columnCount: columns.length,
2109
+ columnIndexById,
2110
+ }
2111
+ cachedRowCtx = rowCtx
1773
2112
  }
1774
2113
 
1775
- cachedBaseRows = new Array(options.data.length)
2114
+ cachedBaseRows = new Array(data.length)
1776
2115
  const getRowId = options.getRowId
1777
2116
  const m = BASE_ROW_METHODS as unknown as {
1778
2117
  getCanExpand: Row<TData>['getCanExpand']
@@ -1783,8 +2122,18 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1783
2122
  getAllCells: Row<TData>['getAllCells']
1784
2123
  getCellValueByColumnId: Row<TData>['getCellValueByColumnId']
1785
2124
  }
1786
- for (let index = 0; index < options.data.length; index++) {
1787
- const original = options.data[index]!
2125
+ for (let index = 0; index < data.length; index++) {
2126
+ const original = data[index]!
2127
+ const kept = previous?.[index] as BaseRowState<TData> | undefined
2128
+ if (kept && kept.original === original) {
2129
+ kept[ROW_VALUES] = null
2130
+ kept[ROW_CELLS] = null
2131
+ cachedBaseRows[index] = kept
2132
+ continue
2133
+ }
2134
+ // Past a quarter of the rows the merge stops being cheaper than a
2135
+ // sort with the ranking pass; let the stages do the full work.
2136
+ if (replacedRows && replacedRows.size * 4 >= data.length) replacedRows = null
1788
2137
  // `_values` and `_cells` stay null until something reads them - a
1789
2138
  // 100k-row grid showing twenty rows must not materialise every row's
1790
2139
  // values or cell objects to paint.
@@ -1796,6 +2145,10 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1796
2145
  [ROW_CTX]: rowCtx,
1797
2146
  [ROW_VALUES]: null,
1798
2147
  [ROW_CELLS]: null,
2148
+ [ROW_FILTER_GEN]: 0,
2149
+ [ROW_FILTER_POS]: -1,
2150
+ [ROW_SORT_DROPPED]: 0,
2151
+ [ROW_SORT_KEY]: undefined,
1799
2152
  getCanExpand: m.getCanExpand,
1800
2153
  getIsExpanded: m.getIsExpanded,
1801
2154
  toggleExpanded: m.toggleExpanded,
@@ -1805,7 +2158,15 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1805
2158
  getCellValueByColumnId: m.getCellValueByColumnId,
1806
2159
  }
1807
2160
  cachedBaseRows[index] = row
2161
+ if (replacedRows && kept) replacedRows.set(kept, row)
1808
2162
  }
2163
+ // A new array with every object the same is the documented way to
2164
+ // say "I changed rows in place, re-read them" (docs/help/cells/
2165
+ // view-refresh.md). Nothing was replaced, so there is nothing to
2166
+ // repair around: the stages recompute from the current values. A
2167
+ // repair assumes the rows it keeps are unchanged in value as well as
2168
+ // identity, which is the immutable-update contract a tick follows.
2169
+ if (replacedRows && replacedRows.size === 0) replacedRows = null
1809
2170
  }
1810
2171
 
1811
2172
  // Only the slices a pipeline stage actually READS belong in this key.
@@ -1852,9 +2213,17 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1852
2213
  pipeline.expandedRowModel,
1853
2214
  pipeline.paginatedRowModel,
1854
2215
  ]
1855
- ordered.forEach((fn) => {
1856
- if (fn) rows = fn({ table: grid, rows })
1857
- })
2216
+ // The stages read the replacements through `replacedRowsOf` while the
2217
+ // run lasts; a stage that runs outside one sees null.
2218
+ replacedRowsByGrid.set(grid, replacedRows)
2219
+ try {
2220
+ ordered.forEach((fn) => {
2221
+ if (fn) rows = fn({ table: grid, rows })
2222
+ })
2223
+ } finally {
2224
+ replacedRowsByGrid.set(grid, null)
2225
+ replacedRows = null
2226
+ }
1858
2227
  cachedPipeline = options._rowModels
1859
2228
  cachedSlices = currentSlices
1860
2229
  cachedRowModelBaseRows = cachedBaseRows
@@ -14,6 +14,8 @@ interface ColSpec {
14
14
  editable?: boolean | ((cellCtx: any) => boolean)
15
15
  editorType?: string
16
16
  editorMultiple?: boolean
17
+ validate?: (p: { value: unknown; row: any }) => string | boolean | null | undefined
18
+ rejectInvalid?: boolean
17
19
  }
18
20
 
19
21
  interface FakeOptions {
@@ -40,6 +42,8 @@ function makeCtx(opts: FakeOptions = {}) {
40
42
  editable: c.editable,
41
43
  editorType: c.editorType,
42
44
  editorMultiple: c.editorMultiple,
45
+ validate: c.validate,
46
+ rejectInvalid: c.rejectInvalid,
43
47
  },
44
48
  }))
45
49
 
@@ -381,6 +385,54 @@ describe('saveEditingCell', () => {
381
385
  expect(ctx.internalData[0].a).toEqual(['x', 'y'])
382
386
  })
383
387
 
388
+ it('refuses a value the column validates against when rejectInvalid is set', () => {
389
+ const onCellValueChange = vi.fn()
390
+ const { ctx, ed } = editingFor({
391
+ columns: [{
392
+ id: 'b', field: 'b', editorType: 'number', rejectInvalid: true,
393
+ validate: ({ value }) => (Number(value) < 0 ? 'Amount must be positive' : null),
394
+ }],
395
+ data: [{ b: 5 }],
396
+ })
397
+ ctx.props.onCellValueChange = onCellValueChange
398
+ ctx.editingCell = { rowId: '0', columnId: 'b', editorType: 'number', value: '-5' }
399
+ ed.saveEditingCell()
400
+ // The row, the overlay, the history and the consumer are untouched; the
401
+ // editor closes.
402
+ expect(ctx.internalData[0].b).toBe(5)
403
+ expect('0:b' in ctx.editedCellValues).toBe(false)
404
+ expect(ctx.history).toHaveLength(0)
405
+ expect(onCellValueChange).not.toHaveBeenCalled()
406
+ expect(ctx.editingCell).toBeNull()
407
+
408
+ // A value the rule accepts still lands.
409
+ ctx.editingCell = { rowId: '0', columnId: 'b', editorType: 'number', value: '7' }
410
+ ed.saveEditingCell()
411
+ expect(ctx.internalData[0].b).toBe(7)
412
+ expect(onCellValueChange).toHaveBeenCalledTimes(1)
413
+ })
414
+
415
+ it('without rejectInvalid a flagged value lands, as before', () => {
416
+ const { ctx, ed } = editingFor({
417
+ columns: [{ id: 'b', field: 'b', editorType: 'number', validate: () => 'always wrong' }],
418
+ data: [{ b: 5 }],
419
+ })
420
+ ctx.editingCell = { rowId: '0', columnId: 'b', editorType: 'number', value: '-5' }
421
+ ed.saveEditingCell()
422
+ expect(ctx.internalData[0].b).toBe(-5)
423
+ })
424
+
425
+ it('checks the value after valueParser, so the rule sees what would be stored', () => {
426
+ const { ctx, ed } = editingFor({
427
+ columns: [{ id: 'b', field: 'b', editorType: 'number', rejectInvalid: true, validate: ({ value }) => (Number(value) > 100 ? false : null) }],
428
+ data: [{ b: 5 }],
429
+ })
430
+ ;(ctx.allColumns[0].columnDef as any).valueParser = ({ newValue }: { newValue: number }) => newValue * 100
431
+ ctx.editingCell = { rowId: '0', columnId: 'b', editorType: 'number', value: '2' }
432
+ ed.saveEditingCell()
433
+ expect(ctx.internalData[0].b).toBe(5) // 200 after the parser, refused
434
+ })
435
+
384
436
  it('pushes a history step and bumps the version on a real change', () => {
385
437
  const { ctx, ed } = editingFor({
386
438
  columns: [{ id: 'a', field: 'a', editorType: 'text' }],