@svgrid/grid 3.0.5 → 3.0.7

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 (166) hide show
  1. package/README.md +28 -7
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +23 -3
  4. package/dist/SvGrid.controller.svelte.js +235 -54
  5. package/dist/SvGrid.css +143 -7
  6. package/dist/SvGrid.helpers.js +2 -4
  7. package/dist/SvGrid.svelte +223 -38
  8. package/dist/SvGrid.types.d.ts +77 -4
  9. package/dist/SvGridDropdown.svelte +20 -0
  10. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  11. package/dist/SvModal.svelte +22 -0
  12. package/dist/SvModal.svelte.d.ts +7 -0
  13. package/dist/a11y/dismissable.d.ts +10 -0
  14. package/dist/a11y/dismissable.js +24 -2
  15. package/dist/build-api.js +81 -31
  16. package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-BgUS-W3o.js} +151 -142
  17. package/dist/cdn/GridMenus-CcwU8I4t.js +644 -0
  18. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  19. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  21. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-9yz-SbdZ.js} +2 -2
  23. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-D7-ql1Hd.js} +2 -2
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DMUOFxOe.js} +4 -4
  25. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-N7ik0oWy.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-CB_Jnm2k.js} +4 -4
  27. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-Ca7gF8ZK.js} +4 -4
  28. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
  29. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-B1LFgoPP.js} +2 -2
  31. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-By3n7DGZ.js} +2 -2
  32. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  33. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  34. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  35. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  36. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-Mnv84zoE.js} +28 -6
  37. package/dist/cdn/{chart-Dz7SqMXH.js → chart-By7h2Guv.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-BC-tOgFY.js} +1 -1
  39. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DUaXr-sb.js} +1 -1
  40. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-BDtrNEiA.js} +1 -1
  41. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-C1aOAPBZ.js} +1 -1
  42. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  43. package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
  44. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-y1wdyCcv.js} +93 -97
  45. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  46. package/dist/cdn/{server-block-cache-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
  47. package/dist/cdn/{src-jcFMkBnN.js → src-25JmJ_7g.js} +4768 -4149
  48. package/dist/cdn/{src-Bklofsad.js → src-kE9B98r6.js} +4796 -4177
  49. package/dist/cdn/svgrid.js +18 -18
  50. package/dist/cdn/svgrid.svelte-external.js +18 -18
  51. package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
  52. package/dist/cell-formatting.d.ts +9 -0
  53. package/dist/cell-formatting.js +52 -1
  54. package/dist/cell-render.js +25 -6
  55. package/dist/clipboard.d.ts +2 -2
  56. package/dist/clipboard.js +72 -23
  57. package/dist/columns.js +7 -2
  58. package/dist/conditional-formatting.d.ts +2 -0
  59. package/dist/conditional-formatting.js +2 -0
  60. package/dist/core.d.ts +22 -6
  61. package/dist/core.js +478 -40
  62. package/dist/editing.js +24 -0
  63. package/dist/export-format.d.ts +8 -0
  64. package/dist/export-format.js +23 -16
  65. package/dist/grid-messages.d.ts +4 -0
  66. package/dist/grid-messages.js +4 -0
  67. package/dist/headless.d.ts +5 -0
  68. package/dist/headless.js +12 -0
  69. package/dist/history.d.ts +21 -0
  70. package/dist/history.js +34 -4
  71. package/dist/index.d.ts +1 -1
  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 +6 -0
  77. package/dist/row-resize.js +22 -1
  78. package/dist/selection.js +1 -1
  79. package/dist/server-block-cache.d.ts +5 -0
  80. package/dist/server-block-cache.js +8 -0
  81. package/dist/server-data-source.d.ts +35 -3
  82. package/dist/server-data-source.js +11 -2
  83. package/dist/server.d.ts +1 -1
  84. package/dist/sparkline.js +7 -5
  85. package/dist/svgrid-wrapper.types.d.ts +33 -0
  86. package/dist/validate.js +9 -1
  87. package/dist/virtualization/column-virtualizer.d.ts +1 -0
  88. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
  89. package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
  90. package/dist/virtualization/virtualizer.d.ts +3 -0
  91. package/dist/virtualization/virtualizer.js +14 -1
  92. package/package.json +6 -1
  93. package/src/GridMenus.svelte +34 -4
  94. package/src/SvGrid.controller.svelte.ts +236 -57
  95. package/src/SvGrid.css +143 -7
  96. package/src/SvGrid.helpers.ts +2 -4
  97. package/src/SvGrid.svelte +223 -38
  98. package/src/SvGrid.types.ts +77 -4
  99. package/src/SvGridDropdown.svelte +20 -0
  100. package/src/SvModal.svelte +22 -0
  101. package/src/SvModal.test.ts +30 -1
  102. package/src/a11y/dismissable.test.ts +10 -1
  103. package/src/a11y/dismissable.ts +22 -2
  104. package/src/build-api.ts +85 -27
  105. package/src/cell-formatting.test.ts +17 -0
  106. package/src/cell-formatting.ts +49 -1
  107. package/src/cell-render.test.ts +24 -2
  108. package/src/cell-render.ts +24 -8
  109. package/src/clipboard.test.ts +107 -0
  110. package/src/clipboard.ts +62 -23
  111. package/src/columns.test.ts +4 -4
  112. package/src/columns.ts +7 -2
  113. package/src/conditional-formatting.test.ts +8 -0
  114. package/src/conditional-formatting.ts +3 -0
  115. package/src/core.sort.test.ts +86 -0
  116. package/src/core.tick-repair.test.ts +287 -0
  117. package/src/core.ts +478 -40
  118. package/src/editing.test.ts +52 -0
  119. package/src/editing.ts +25 -0
  120. package/src/export-dates.test.ts +114 -0
  121. package/src/export-format.ts +26 -14
  122. package/src/grid-messages.test.ts +7 -0
  123. package/src/grid-messages.ts +10 -0
  124. package/src/headless.ts +24 -0
  125. package/src/history.test.ts +49 -1
  126. package/src/history.ts +48 -2
  127. package/src/index.ts +2 -0
  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 +6 -0
  135. package/src/row-resize.ts +24 -1
  136. package/src/selection.ts +1 -1
  137. package/src/server-block-cache.test.ts +27 -0
  138. package/src/server-block-cache.ts +13 -0
  139. package/src/server-data-source.infinite.test.ts +62 -0
  140. package/src/server-data-source.test.ts +19 -0
  141. package/src/server-data-source.ts +48 -4
  142. package/src/server.ts +2 -0
  143. package/src/sparkline.test.ts +7 -0
  144. package/src/sparkline.ts +6 -4
  145. package/src/svgrid-wrapper.types.ts +32 -0
  146. package/src/svgrid.api.test.ts +21 -0
  147. package/src/svgrid.charting.test.ts +12 -4
  148. package/src/svgrid.context-menu.test.ts +12 -0
  149. package/src/svgrid.data-push.svelte.test.ts +94 -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 +1 -1
  153. package/src/svgrid.live-update-paths.svelte.test.ts +186 -0
  154. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  155. package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
  156. package/src/svgrid.row-pinning.test.ts +15 -1
  157. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  158. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  159. package/src/svgrid.upsell-license.test.ts +1 -1
  160. package/src/test-setup.ts +12 -0
  161. package/src/transaction.test.ts +58 -0
  162. package/src/validate.ts +7 -1
  163. package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
  164. package/src/virtualization/virtualizer.test.ts +44 -0
  165. package/src/virtualization/virtualizer.ts +13 -1
  166. package/dist/cdn/GridMenus-DLL-qZeu.js +0 -635
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
  *
@@ -734,13 +745,52 @@ export function tableFeatures<T extends TableFeatures>(features: T): T {
734
745
  * text; set a column's type or supply your own comparator to override.
735
746
  */
736
747
  export const sortFns = {
737
- auto: (a: unknown, b: unknown) => String(a).localeCompare(String(b)),
738
- number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),
739
- date: (a: unknown, b: unknown) => {
740
- const aa = new Date(a as any).getTime()
741
- const bb = new Date(b as any).getTime()
742
- return aa - bb
743
- },
748
+ auto: (a: unknown, b: unknown) => compareAutoValues(a, b),
749
+ number: (a: unknown, b: unknown) => compareNumericKeys(numberSortKey(a), numberSortKey(b)),
750
+ date: (a: unknown, b: unknown) => compareNumericKeys(dateSortKey(a), dateSortKey(b)),
751
+ }
752
+
753
+ /** Null, undefined and the empty string: the values every built-in sort puts first. */
754
+ function isBlankSortValue(value: unknown): boolean {
755
+ return value == null || value === ''
756
+ }
757
+
758
+ /**
759
+ * Sort key for the `number` comparator. Blanks and anything that does not
760
+ * coerce to a number ('n/a', a placeholder) become -Infinity, so they sort
761
+ * together ahead of every number instead of producing NaN. A NaN key made the
762
+ * comparator inconsistent - NaN compared equal to everything while the numbers
763
+ * around it did not - and `Array.prototype.sort` then returned a visibly
764
+ * unsorted column (#104).
765
+ */
766
+ function numberSortKey(value: unknown): number {
767
+ if (isBlankSortValue(value)) return -Infinity
768
+ const n = Number(value)
769
+ return Number.isNaN(n) ? -Infinity : n
770
+ }
771
+
772
+ /** Sort key for the `date` comparator; blanks and invalid dates sort first. */
773
+ function dateSortKey(value: unknown): number {
774
+ if (isBlankSortValue(value)) return -Infinity
775
+ const t = new Date(value as any).getTime()
776
+ return Number.isNaN(t) ? -Infinity : t
777
+ }
778
+
779
+ /**
780
+ * The `auto` comparator: blanks first, then numbers in numeric order, then
781
+ * everything else as collated text. Ordering by kind before value keeps it
782
+ * transitive on mixed columns - comparing a number to a string as text
783
+ * instead would give 2 < 10, 10 < '1a' and '1a' < 2, a cycle.
784
+ */
785
+ function compareAutoValues(a: unknown, b: unknown): number {
786
+ const aBlank = isBlankSortValue(a)
787
+ const bBlank = isBlankSortValue(b)
788
+ if (aBlank || bBlank) return aBlank === bBlank ? 0 : aBlank ? -1 : 1
789
+ const aNum = typeof a === 'number'
790
+ const bNum = typeof b === 'number'
791
+ if (aNum && bNum) return compareNumericKeys(numberSortKey(a), numberSortKey(b))
792
+ if (aNum !== bNum) return aNum ? -1 : 1
793
+ return compareCollatedKeys(String(a), String(b))
744
794
  }
745
795
 
746
796
  /**
@@ -786,12 +836,29 @@ type BaseRowCtx<TData extends RowData> = {
786
836
  const ROW_CTX = Symbol('svgrid.row.ctx')
787
837
  const ROW_VALUES = Symbol('svgrid.row.values')
788
838
  const ROW_CELLS = Symbol('svgrid.row.cells')
839
+ // Where the filtered stage last put this row: the generation of that full
840
+ // filter and the row's index in its output. Lets a tick swap a replaced row
841
+ // into place without re-filtering everything (see createFilteredRowModel).
842
+ const ROW_FILTER_GEN = Symbol('svgrid.row.filterGen')
843
+ const ROW_FILTER_POS = Symbol('svgrid.row.filterPos')
844
+ // Stamped on the rows a sort repair drops, so the merge skips them with one
845
+ // property read per row instead of a Set lookup (3 ms per 100k rows).
846
+ const ROW_SORT_DROPPED = Symbol('svgrid.row.sortDropped')
847
+ // The key(s) the last full sort computed for a row: the key itself for a
848
+ // one-clause sort, an array for several. Read by the repair path so a tick
849
+ // never recomputes a kept row's key.
850
+ const ROW_SORT_KEY = Symbol('svgrid.row.sortKey')
851
+ let sortDropGeneration = 0
789
852
 
790
853
  /** A base row's private fields, on top of the public {@link Row} surface. */
791
854
  type BaseRowState<TData extends RowData> = Row<TData> & {
792
855
  [ROW_CTX]: BaseRowCtx<TData>
793
856
  [ROW_VALUES]: Array<unknown> | null
794
857
  [ROW_CELLS]: Array<Cell<TData>> | null
858
+ [ROW_FILTER_GEN]: number
859
+ [ROW_FILTER_POS]: number
860
+ [ROW_SORT_DROPPED]: number
861
+ [ROW_SORT_KEY]: unknown
795
862
  }
796
863
 
797
864
  /**
@@ -912,9 +979,26 @@ export function createCoreRowModel<TData extends RowData>(): RowModelFactory<TDa
912
979
  * `columnFilteringFeature`; without it there are no filters to apply.
913
980
  */
914
981
  export function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {
982
+ /**
983
+ * The last full filter, so a tick can repair it. When `getRowModel`
984
+ * reports which rows were replaced (`replacedRowsOf`) and the filter state
985
+ * and columns are the same objects as last time, each replacement is tested
986
+ * on its own: a row that stays in keeps its slot, a row that stays out
987
+ * stays out, and a row whose membership flips sends the whole thing back
988
+ * through the full pass, because an insertion has to land in data order.
989
+ * Positions come from stamps on the rows (ROW_FILTER_GEN / ROW_FILTER_POS)
990
+ * written by the full pass; no map is built.
991
+ */
992
+ let cache: { input: Array<Row<TData>>; output: Array<Row<TData>>; filters: ColumnFiltersState; columns: Array<Column<TData>>; gen: number } | null = null
993
+ let generation = 0
994
+
915
995
  return ({ table, rows }) => {
916
996
  const filters: ColumnFiltersState = table.getState().columnFilters ?? []
917
- if (!filters.length) return rows
997
+ if (!filters.length) {
998
+ cache = null
999
+ return rows
1000
+ }
1001
+ const columns = table.getAllColumns()
918
1002
 
919
1003
  // Resolve each filter's match function once, outside the row loop.
920
1004
  const compiled = filters.map((filter) => ({
@@ -922,8 +1006,7 @@ export function createFilteredRowModel<TData extends RowData>(): RowModelFactory
922
1006
  value: filter.value,
923
1007
  fn: filter.fn ? filterFns[filter.fn] : filterFns.includesString,
924
1008
  }))
925
-
926
- return rows.filter((row) => {
1009
+ const passes = (row: Row<TData>) => {
927
1010
  for (let i = 0; i < compiled.length; i++) {
928
1011
  const filter = compiled[i]!
929
1012
  // `getCellValueByColumnId` rather than `getAllCells().find(...)`.
@@ -935,7 +1018,48 @@ export function createFilteredRowModel<TData extends RowData>(): RowModelFactory
935
1018
  if (!filter.fn(row.getCellValueByColumnId(filter.id), filter.value as any)) return false
936
1019
  }
937
1020
  return true
938
- })
1021
+ }
1022
+
1023
+ if (cache && cache.filters === filters && cache.columns === columns) {
1024
+ if (cache.input === rows) return cache.output
1025
+ const replaced = replacedRowsOf(table)
1026
+ if (replaced && rows.length === cache.input.length) {
1027
+ let out: Array<Row<TData>> | null = null
1028
+ let ok = true
1029
+ for (let i = 0; i < rows.length && ok; i++) {
1030
+ const prev = cache.input[i]! as BaseRowState<TData>
1031
+ const next = rows[i]! as BaseRowState<TData>
1032
+ if (next === prev) continue
1033
+ if (replaced.get(prev) !== next) { ok = false; break }
1034
+ const wasIn = prev[ROW_FILTER_GEN] === cache.gen
1035
+ const isIn = passes(next)
1036
+ if (wasIn !== isIn) { ok = false; break }
1037
+ if (!isIn) continue
1038
+ out ??= cache.output.slice()
1039
+ const pos = prev[ROW_FILTER_POS]
1040
+ out[pos] = next
1041
+ next[ROW_FILTER_GEN] = cache.gen
1042
+ next[ROW_FILTER_POS] = pos
1043
+ }
1044
+ if (ok) {
1045
+ const output = out ?? cache.output
1046
+ cache = { ...cache, input: rows, output }
1047
+ return output
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ const gen = ++generation
1053
+ const output: Array<Row<TData>> = []
1054
+ for (let i = 0; i < rows.length; i++) {
1055
+ const row = rows[i]! as BaseRowState<TData>
1056
+ if (!passes(row)) continue
1057
+ row[ROW_FILTER_GEN] = gen
1058
+ row[ROW_FILTER_POS] = output.length
1059
+ output.push(row)
1060
+ }
1061
+ cache = { input: rows, output, filters, columns, gen }
1062
+ return output
939
1063
  }
940
1064
  }
941
1065
  /**
@@ -1225,6 +1349,15 @@ export function flattenTreeData<T extends RowData>(
1225
1349
  export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
1226
1350
  return ({ table, rows }) => {
1227
1351
  const expanded: ExpandedState = table.getState().expanded ?? {}
1352
+ // Nothing expanded means every collapsed row hides its children and the
1353
+ // output is the input, element for element. Returning it as is keeps the
1354
+ // array identity the stages downstream and the render window key on, and
1355
+ // skips a 100k-element copy per data change on a flat grid.
1356
+ let anyExpanded = false
1357
+ for (const id in expanded) {
1358
+ if (expanded[id]) { anyExpanded = true; break }
1359
+ }
1360
+ if (!anyExpanded) return rows
1228
1361
  const flattened: Array<Row<TData>> = []
1229
1362
  const visit = (row: Row<TData>) => {
1230
1363
  flattened.push(row)
@@ -1241,13 +1374,76 @@ export function createExpandedRowModel<TData extends RowData>(): RowModelFactory
1241
1374
  * override the built-in {@link sortFns} - useful for locale-aware or
1242
1375
  * domain-specific ordering.
1243
1376
  */
1377
+ /**
1378
+ * Which rows the last `getRowModel` rebuild replaced, old row -> new row, for
1379
+ * the duration of the pipeline run that follows it.
1380
+ *
1381
+ * A live feed replaces a few hundred row objects in a hundred-thousand-row
1382
+ * array and hands the grid the new array. The base rows are rebuilt reusing
1383
+ * every row whose data object is the same (see `getRowModel`), so the rebuild
1384
+ * knows exactly which rows are new. The filtered and sorted stages read that
1385
+ * here and repair their previous output instead of recomputing it, when the
1386
+ * replacements are few and nothing else changed. Cleared after the run; a
1387
+ * stage that reads it outside a run sees null and does the full work.
1388
+ *
1389
+ * Keyed on the grid object in a WeakMap rather than carried on the public
1390
+ * `SvGrid` type, so the stage signature stays `({ table, rows }) => rows`.
1391
+ */
1392
+ const replacedRowsByGrid = new WeakMap<object, ReadonlyMap<Row<any>, Row<any>> | null>()
1393
+
1394
+ /** The rows `getRowModel` replaced in the rebuild this pipeline run follows, or null. */
1395
+ export function replacedRowsOf(table: SvGrid<any>): ReadonlyMap<Row<any>, Row<any>> | null {
1396
+ return replacedRowsByGrid.get(table) ?? null
1397
+ }
1398
+
1399
+ /**
1400
+ * The sorting stage of the row pipeline: orders the rows by the grid's
1401
+ * `sorting` state with the built-in comparators (or the `localSortFns` map
1402
+ * handed in), stable, equal keys keeping their data order. A tick that
1403
+ * replaces a few rows out of many is repaired incrementally rather than
1404
+ * re-sorted (see the note inside). Registered by `rowSortingFeature`; an app
1405
+ * only calls this to install its own comparator set.
1406
+ */
1244
1407
  export function createSortedRowModel<TData extends RowData>(
1245
1408
  localSortFns: typeof sortFns = sortFns,
1246
1409
  ): RowModelFactory<TData> {
1247
- return function sortedRowModelStage({ table, rows }) {
1248
- const sorting = table.getState().sorting ?? []
1249
- if (!sorting.length) return rows
1410
+ /**
1411
+ * What the last full sort produced, kept so a tick can repair it.
1412
+ *
1413
+ * A live feed replaces a few hundred row objects out of a hundred thousand
1414
+ * and hands the grid a new array. Re-sorting all of it costs the same as
1415
+ * the first sort did (115-149 ms in the browser for 100k rows, see
1416
+ * docs/help/benchmarks.md); the order it would produce differs from the
1417
+ * previous one only where the replaced rows land. So the previous output
1418
+ * is kept, with its sort keys aligned to it, and when `getRowModel` says
1419
+ * which rows were replaced (see `replacedRowsOf`) the stage drops those,
1420
+ * sorts the replacements among themselves and merges them back in one
1421
+ * linear pass: O(n + k log k) instead of O(n log n) key builds and
1422
+ * comparisons.
1423
+ *
1424
+ * The repair is only attempted when everything else is the same object as
1425
+ * last time - the sorting state, the columns, and every input row except
1426
+ * the replaced ones - and it produces exactly what the full sort would:
1427
+ * equal keys fall back to the data index, which is the order a stable sort
1428
+ * over data-ordered input keeps.
1429
+ */
1430
+ let cache: {
1431
+ input: Array<Row<TData>>
1432
+ output: Array<Row<TData>>
1433
+ sorting: SortingState
1434
+ columns: Array<Column<TData>>
1435
+ clauses: Array<SortClause>
1436
+ } | null = null
1437
+
1438
+ type SortClause = {
1439
+ desc: boolean
1440
+ compare: (a: any, b: any) => number
1441
+ /** The key for one row, or `MISSING_KEY` when it cannot be produced incrementally. */
1442
+ keyOf: (row: Row<TData>) => any
1443
+ }
1444
+ const MISSING_KEY = Symbol('svgrid.sort.missing-key')
1250
1445
 
1446
+ function fullSort(rows: Array<Row<TData>>, sorting: SortingState, allColumns: Array<Column<TData>>) {
1251
1447
  // Resolve every clause ONCE, before sorting.
1252
1448
  //
1253
1449
  // This used to live inside the comparator, so `getAllColumns().find(...)`
@@ -1255,11 +1451,11 @@ export function createSortedRowModel<TData extends RowData>(
1255
1451
  // 1,528,947 array scans, and a three-clause sort made 3,933,751 (measured;
1256
1452
  // `pnpm bench --case=sort-1col`). The comparator is called O(n log n)
1257
1453
  // times, so anything inside it that is not O(1) sets the cost of the sort.
1258
- const allColumns = table.getAllColumns()
1259
1454
  const clauses: Array<{
1260
1455
  keys: Array<any>
1261
1456
  desc: boolean
1262
1457
  compare: (a: any, b: any) => number
1458
+ keyOf: (row: Row<TData>) => any
1263
1459
  }> = []
1264
1460
 
1265
1461
  for (const clause of sorting) {
@@ -1288,15 +1484,36 @@ export function createSortedRowModel<TData extends RowData>(
1288
1484
  const n = rows.length
1289
1485
  let keys: Array<any> = new Array(n)
1290
1486
  let compare: (a: any, b: any) => number
1487
+ let keyOf: (row: Row<TData>) => any
1488
+ let autoMode: 'number' | 'mixed' | 'text' | undefined
1291
1489
 
1292
1490
  if (comparator === sortFns.number) {
1293
- for (let i = 0; i < n; i++) keys[i] = Number(rows[i]!.getCellValueByColumnId(columnId) ?? 0)
1491
+ keyOf = (row) => numberSortKey(row.getCellValueByColumnId(columnId))
1492
+ for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
1294
1493
  compare = compareNumericKeys
1295
1494
  } 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()
1495
+ keyOf = (row) => dateSortKey(row.getCellValueByColumnId(columnId))
1496
+ for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
1497
+ compare = compareNumericKeys
1498
+ } else if (comparator === sortFns.auto && (autoMode = autoSortMode(rows, columnId)) === 'number') {
1499
+ // A column with no declared type whose values are all numbers. Sorting
1500
+ // it as text put 10 before 2 (#104).
1501
+ keyOf = (row) => {
1502
+ const value = row.getCellValueByColumnId(columnId)
1503
+ // A text value arriving in a tick changes the mode; only a full sort
1504
+ // can place it.
1505
+ return typeof value === 'number' || isBlankSortValue(value)
1506
+ ? numberSortKey(value)
1507
+ : MISSING_KEY
1298
1508
  }
1509
+ for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
1299
1510
  compare = compareNumericKeys
1511
+ } else if (autoMode === 'mixed') {
1512
+ // Numbers and text in one column. Rare, so no key tricks: the raw
1513
+ // values through the pairwise comparator, which orders by kind first.
1514
+ keyOf = (row) => row.getCellValueByColumnId(columnId)
1515
+ for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
1516
+ compare = compareAutoValues
1300
1517
  } else if (comparator === sortFns.auto) {
1301
1518
  const strings: string[] = new Array(n)
1302
1519
  // Decide whether ranking is worth attempting BEFORE paying for it.
@@ -1319,7 +1536,7 @@ export function createSortedRowModel<TData extends RowData>(
1319
1536
  const sample = new Set<string>()
1320
1537
  let sampled = 0
1321
1538
  for (let i = 0; i < n; i += stride) {
1322
- sample.add(String(rows[i]!.getCellValueByColumnId(columnId)))
1539
+ sample.add(autoTextKey(rows[i]!.getCellValueByColumnId(columnId)))
1323
1540
  sampled++
1324
1541
  }
1325
1542
  // Only attempt ranking when the sample suggests real repetition.
@@ -1327,7 +1544,7 @@ export function createSortedRowModel<TData extends RowData>(
1327
1544
  }
1328
1545
 
1329
1546
  for (let i = 0; i < n; i++) {
1330
- const s = String(rows[i]!.getCellValueByColumnId(columnId))
1547
+ const s = autoTextKey(rows[i]!.getCellValueByColumnId(columnId))
1331
1548
  strings[i] = s
1332
1549
  if (distinct) {
1333
1550
  distinct.add(s)
@@ -1354,19 +1571,32 @@ export function createSortedRowModel<TData extends RowData>(
1354
1571
  for (let i = 0; i < ordered.length; i++) rankOf.set(ordered[i]!, i)
1355
1572
  for (let i = 0; i < n; i++) keys[i] = rankOf.get(strings[i]!)!
1356
1573
  compare = compareNumericKeys
1574
+ // A replacement whose text is not among the ranked values has no
1575
+ // rank; the repair path treats that as "sort everything again".
1576
+ keyOf = (row) => {
1577
+ const value = row.getCellValueByColumnId(columnId)
1578
+ // A number arriving in a tick makes the column mixed; resort fully.
1579
+ if (typeof value === 'number') return MISSING_KEY
1580
+ return rankOf.get(autoTextKey(value)) ?? MISSING_KEY
1581
+ }
1357
1582
  } else {
1358
1583
  keys = strings
1359
1584
  compare = compareCollatedKeys
1585
+ keyOf = (row) => {
1586
+ const value = row.getCellValueByColumnId(columnId)
1587
+ return typeof value === 'number' ? MISSING_KEY : autoTextKey(value)
1588
+ }
1360
1589
  }
1361
1590
  } else {
1362
- for (let i = 0; i < n; i++) keys[i] = rows[i]!.getCellValueByColumnId(columnId)
1591
+ keyOf = (row) => row.getCellValueByColumnId(columnId)
1592
+ for (let i = 0; i < n; i++) keys[i] = keyOf(rows[i]!)
1363
1593
  compare = comparator
1364
1594
  }
1365
1595
 
1366
- clauses.push({ keys, desc: clause.desc, compare })
1596
+ clauses.push({ keys, desc: clause.desc, compare, keyOf })
1367
1597
  }
1368
1598
 
1369
- if (!clauses.length) return rows
1599
+ if (!clauses.length) return null
1370
1600
 
1371
1601
  // Sort an index array, then materialise. `Array.prototype.sort` is stable,
1372
1602
  // so equal keys keep their original relative order exactly as the previous
@@ -1415,19 +1645,194 @@ export function createSortedRowModel<TData extends RowData>(
1415
1645
 
1416
1646
  const sorted = new Array<Row<TData>>(rows.length)
1417
1647
  for (let i = 0; i < order.length; i++) sorted[i] = rows[order[i]!]!
1418
- return sorted
1648
+ // Each row keeps its key(s), so a later tick can place a replacement
1649
+ // among the kept rows without reading any of them again. One property
1650
+ // write per row; an array per row only when there are several clauses.
1651
+ if (clauses.length === 1) {
1652
+ const keys = clauses[0]!.keys
1653
+ for (let i = 0; i < rows.length; i++) (rows[i] as any)[ROW_SORT_KEY] = keys[i]
1654
+ } else {
1655
+ for (let i = 0; i < rows.length; i++) {
1656
+ const per = new Array(clauses.length)
1657
+ for (let k = 0; k < clauses.length; k++) per[k] = clauses[k]!.keys[i]
1658
+ ;(rows[i] as any)[ROW_SORT_KEY] = per
1659
+ }
1660
+ }
1661
+ return { sorted, clauses: clauses.map(({ desc, compare, keyOf }) => ({ desc, compare, keyOf })) }
1662
+ }
1663
+
1664
+ /**
1665
+ * The previous output with the replaced rows swapped for their
1666
+ * replacements, in sorted position. Null when the input is not the cached
1667
+ * input plus replacements, or a replacement has no incremental key.
1668
+ *
1669
+ * Every row carries the key(s) the last full sort computed for it
1670
+ * (ROW_SORT_KEY: the key itself for one clause, an array for several), so
1671
+ * the merge moves row references and nothing else. At 100k rows the loop
1672
+ * that also copied an aligned key array cost 3.7 ms; this one is the
1673
+ * reference copy alone.
1674
+ */
1675
+ function repair(rows: Array<Row<TData>>, replaced: ReadonlyMap<Row<any>, Row<any>>) {
1676
+ const c = cache!
1677
+ if (rows.length !== c.input.length) return null
1678
+ // Which rows of THIS stage's input changed. The map may name rows a
1679
+ // stage upstream filtered out; only the ones that reach here matter.
1680
+ const dropGen = ++sortDropGeneration
1681
+ const added: Array<Row<TData>> = []
1682
+ for (let i = 0; i < rows.length; i++) {
1683
+ const prev = c.input[i]!
1684
+ const next = rows[i]!
1685
+ if (next === prev) continue
1686
+ if (replaced.get(prev) !== next) return null
1687
+ ;(prev as any)[ROW_SORT_DROPPED] = dropGen
1688
+ added.push(next)
1689
+ }
1690
+ if (!added.length) return c.output
1691
+
1692
+ const clauses = c.clauses
1693
+ const single = clauses.length === 1
1694
+ for (let j = 0; j < added.length; j++) {
1695
+ const row = added[j]! as any
1696
+ if (single) {
1697
+ const key = clauses[0]!.keyOf(row)
1698
+ if (key === MISSING_KEY) return null
1699
+ row[ROW_SORT_KEY] = key
1700
+ } else {
1701
+ const keys = new Array(clauses.length)
1702
+ for (let k = 0; k < clauses.length; k++) {
1703
+ const key = clauses[k]!.keyOf(row)
1704
+ if (key === MISSING_KEY) return null
1705
+ keys[k] = key
1706
+ }
1707
+ row[ROW_SORT_KEY] = keys
1708
+ }
1709
+ }
1710
+
1711
+ // Keys first, then the data index: the tie-break a stable sort over
1712
+ // data-ordered input applies, so the merge lands where a full sort would.
1713
+ const prevOut = c.output
1714
+ const compareRows = single
1715
+ ? (() => {
1716
+ const { compare, desc } = clauses[0]!
1717
+ return (a: Row<TData>, b: Row<TData>) => {
1718
+ const r = compare((a as any)[ROW_SORT_KEY], (b as any)[ROW_SORT_KEY])
1719
+ if (r !== 0) return desc ? -r : r
1720
+ return a.index - b.index
1721
+ }
1722
+ })()
1723
+ : (a: Row<TData>, b: Row<TData>) => {
1724
+ const ka = (a as any)[ROW_SORT_KEY]
1725
+ const kb = (b as any)[ROW_SORT_KEY]
1726
+ for (let k = 0; k < clauses.length; k++) {
1727
+ const clause = clauses[k]!
1728
+ const r = clause.compare(ka[k], kb[k])
1729
+ if (r !== 0) return clause.desc ? -r : r
1730
+ }
1731
+ return a.index - b.index
1732
+ }
1733
+
1734
+ // Sort the replacements among themselves (k log k)...
1735
+ added.sort(compareRows)
1736
+
1737
+ // ...find where each one goes in the previous output by binary search
1738
+ // (k log n comparisons, against the dropped rows too - they are skipped
1739
+ // below, which does not move an insertion point)...
1740
+ const n = prevOut.length
1741
+ const insertAt = new Array<number>(added.length)
1742
+ let lo = 0
1743
+ for (let j = 0; j < added.length; j++) {
1744
+ // Monotone: the replacements are sorted, so each search starts where
1745
+ // the previous one landed.
1746
+ const row = added[j]!
1747
+ let hi = n
1748
+ while (lo < hi) {
1749
+ const mid = (lo + hi) >>> 1
1750
+ if (compareRows(row, prevOut[mid]!) < 0) hi = mid
1751
+ else lo = mid + 1
1752
+ }
1753
+ insertAt[j] = lo
1754
+ }
1755
+
1756
+ // ...then one linear pass that only moves references.
1757
+ const out = new Array<Row<TData>>(rows.length)
1758
+ let w = 0
1759
+ let j = 0
1760
+ let nextInsert = insertAt[0] ?? -1
1761
+ for (let i = 0; i < n; i++) {
1762
+ while (nextInsert === i) {
1763
+ out[w++] = added[j++]!
1764
+ nextInsert = j < added.length ? insertAt[j]! : -1
1765
+ }
1766
+ const prev = prevOut[i]!
1767
+ if ((prev as any)[ROW_SORT_DROPPED] === dropGen) continue
1768
+ out[w++] = prev
1769
+ }
1770
+ while (j < added.length) out[w++] = added[j++]!
1771
+ return out
1772
+ }
1773
+
1774
+ return function sortedRowModelStage({ table, rows }) {
1775
+ const sorting = table.getState().sorting ?? []
1776
+ if (!sorting.length) {
1777
+ cache = null
1778
+ return rows
1779
+ }
1780
+ const columns = table.getAllColumns()
1781
+
1782
+ if (cache && cache.sorting === sorting && cache.columns === columns) {
1783
+ if (cache.input === rows) return cache.output
1784
+ const replaced = replacedRowsOf(table)
1785
+ if (replaced) {
1786
+ const repaired = repair(rows, replaced)
1787
+ if (repaired) {
1788
+ cache = { ...cache, input: rows, output: repaired }
1789
+ return repaired
1790
+ }
1791
+ }
1792
+ }
1793
+
1794
+ const full = fullSort(rows, sorting, columns)
1795
+ if (!full) {
1796
+ cache = null
1797
+ return rows
1798
+ }
1799
+ cache = { input: rows, output: full.sorted, sorting, columns, clauses: full.clauses }
1800
+ return full.sorted
1419
1801
  }
1420
1802
  }
1421
1803
 
1422
1804
  /**
1423
- * Numeric key comparison for the built-in `number` and `date` comparators.
1424
- * Subtraction rather than `<`/`>` on purpose: it reproduces the originals
1425
- * exactly, NaN included. An unparseable date or a non-numeric value yields NaN,
1426
- * and the sort spec turns a NaN comparison result into 0 (SortCompare coerces
1427
- * it), which is the behaviour callers already depend on.
1805
+ * Numeric key comparison for the built-in `number`, `date` and numeric
1806
+ * `auto` comparators. Keys are never NaN (blanks and unparseable values map to
1807
+ * -Infinity), but two -Infinity keys subtract to NaN, so equal keys return 0
1808
+ * explicitly: the multi-clause sort reads any non-zero result, NaN included,
1809
+ * as decided and would skip the next clause.
1428
1810
  */
1429
1811
  function compareNumericKeys(a: number, b: number): number {
1430
- return a - b
1812
+ return a === b ? 0 : a - b
1813
+ }
1814
+
1815
+ /** Text key for the `auto` comparator: blanks become '' so they collate first. */
1816
+ function autoTextKey(value: unknown): string {
1817
+ return isBlankSortValue(value) ? '' : String(value)
1818
+ }
1819
+
1820
+ /**
1821
+ * How the `auto` comparator should treat a column: 'number' when every
1822
+ * non-blank value is a number, 'mixed' when numbers share it with anything
1823
+ * else, 'text' otherwise. One pass, stopping as soon as the answer is known.
1824
+ */
1825
+ function autoSortMode(rows: ReadonlyArray<Row<any>>, columnId: string): 'number' | 'mixed' | 'text' {
1826
+ let numbers = false
1827
+ let other = false
1828
+ for (let i = 0; i < rows.length; i++) {
1829
+ const value = rows[i]!.getCellValueByColumnId(columnId)
1830
+ if (isBlankSortValue(value)) continue
1831
+ if (typeof value === 'number') numbers = true
1832
+ else other = true
1833
+ if (numbers && other) return 'mixed'
1834
+ }
1835
+ return numbers ? 'number' : 'text'
1431
1836
  }
1432
1837
 
1433
1838
  /**
@@ -1545,6 +1950,8 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1545
1950
  let cachedBaseRowsInput: ReadonlyArray<TData> | null = null
1546
1951
  let cachedBaseRowsColumns: Array<Column<TData>> | null = null
1547
1952
  let cachedBaseRows: Array<Row<TData>> = []
1953
+ /** Old row -> new row for the rebuild the next pipeline run follows; see `replacedRowsOf`. */
1954
+ let replacedRows: Map<Row<TData>, Row<TData>> | null = null
1548
1955
  let cachedRowCtx: BaseRowCtx<TData> | null = null
1549
1956
  let cachedRowModel: RowModel<TData> | null = null
1550
1957
  let cachedRowModelBaseRows: Array<Row<TData>> | null = null
@@ -1754,7 +2161,11 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1754
2161
  },
1755
2162
  getRowModel() {
1756
2163
  const columns = grid.getAllColumns()
1757
- if (cachedBaseRowsInput !== options.data || cachedBaseRowsColumns !== columns) {
2164
+ // Read once. `options.data` is a getter when <SvGrid> drives the engine
2165
+ // (a $state.raw signal), and the rebuild below touched it twice per row:
2166
+ // 200k tracked reads per 100k-row tick, 11 ms of a 65 ms tick.
2167
+ const data = options.data
2168
+ if (cachedBaseRowsInput !== data || cachedBaseRowsColumns !== columns) {
1758
2169
  // Same columns as last time: the shared context still describes them,
1759
2170
  // and a row whose data object sits at the same index can keep its
1760
2171
  // row object. A row model that streams blocks hands the grid a new
@@ -1764,7 +2175,11 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1764
2175
  // an app may have changed the object in place before passing a new
1765
2176
  // array - that is the case the old rebuild covered by accident.
1766
2177
  const previous = cachedBaseRowsColumns === columns && cachedRowCtx ? cachedBaseRows : null
1767
- cachedBaseRowsInput = options.data
2178
+ // Same length as last time: a replacement, not an add or remove, so
2179
+ // each new row stands in for the old row at its index and the stages
2180
+ // can repair rather than recompute. Anything else is structural.
2181
+ replacedRows = previous && previous.length === data.length ? new Map() : null
2182
+ cachedBaseRowsInput = data
1768
2183
  cachedBaseRowsColumns = columns
1769
2184
  // O(1) column-id → index lookup so getCellValueByColumnId doesn't do
1770
2185
  // a linear `findIndex` on every cell read (was O(rows × cells × cols)).
@@ -1786,7 +2201,7 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1786
2201
  cachedRowCtx = rowCtx
1787
2202
  }
1788
2203
 
1789
- cachedBaseRows = new Array(options.data.length)
2204
+ cachedBaseRows = new Array(data.length)
1790
2205
  const getRowId = options.getRowId
1791
2206
  const m = BASE_ROW_METHODS as unknown as {
1792
2207
  getCanExpand: Row<TData>['getCanExpand']
@@ -1797,8 +2212,8 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1797
2212
  getAllCells: Row<TData>['getAllCells']
1798
2213
  getCellValueByColumnId: Row<TData>['getCellValueByColumnId']
1799
2214
  }
1800
- for (let index = 0; index < options.data.length; index++) {
1801
- const original = options.data[index]!
2215
+ for (let index = 0; index < data.length; index++) {
2216
+ const original = data[index]!
1802
2217
  const kept = previous?.[index] as BaseRowState<TData> | undefined
1803
2218
  if (kept && kept.original === original) {
1804
2219
  kept[ROW_VALUES] = null
@@ -1806,6 +2221,9 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1806
2221
  cachedBaseRows[index] = kept
1807
2222
  continue
1808
2223
  }
2224
+ // Past a quarter of the rows the merge stops being cheaper than a
2225
+ // sort with the ranking pass; let the stages do the full work.
2226
+ if (replacedRows && replacedRows.size * 4 >= data.length) replacedRows = null
1809
2227
  // `_values` and `_cells` stay null until something reads them - a
1810
2228
  // 100k-row grid showing twenty rows must not materialise every row's
1811
2229
  // values or cell objects to paint.
@@ -1817,6 +2235,10 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1817
2235
  [ROW_CTX]: rowCtx,
1818
2236
  [ROW_VALUES]: null,
1819
2237
  [ROW_CELLS]: null,
2238
+ [ROW_FILTER_GEN]: 0,
2239
+ [ROW_FILTER_POS]: -1,
2240
+ [ROW_SORT_DROPPED]: 0,
2241
+ [ROW_SORT_KEY]: undefined,
1820
2242
  getCanExpand: m.getCanExpand,
1821
2243
  getIsExpanded: m.getIsExpanded,
1822
2244
  toggleExpanded: m.toggleExpanded,
@@ -1826,7 +2248,15 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1826
2248
  getCellValueByColumnId: m.getCellValueByColumnId,
1827
2249
  }
1828
2250
  cachedBaseRows[index] = row
2251
+ if (replacedRows && kept) replacedRows.set(kept, row)
1829
2252
  }
2253
+ // A new array with every object the same is the documented way to
2254
+ // say "I changed rows in place, re-read them" (docs/help/cells/
2255
+ // view-refresh.md). Nothing was replaced, so there is nothing to
2256
+ // repair around: the stages recompute from the current values. A
2257
+ // repair assumes the rows it keeps are unchanged in value as well as
2258
+ // identity, which is the immutable-update contract a tick follows.
2259
+ if (replacedRows && replacedRows.size === 0) replacedRows = null
1830
2260
  }
1831
2261
 
1832
2262
  // Only the slices a pipeline stage actually READS belong in this key.
@@ -1873,9 +2303,17 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1873
2303
  pipeline.expandedRowModel,
1874
2304
  pipeline.paginatedRowModel,
1875
2305
  ]
1876
- ordered.forEach((fn) => {
1877
- if (fn) rows = fn({ table: grid, rows })
1878
- })
2306
+ // The stages read the replacements through `replacedRowsOf` while the
2307
+ // run lasts; a stage that runs outside one sees null.
2308
+ replacedRowsByGrid.set(grid, replacedRows)
2309
+ try {
2310
+ ordered.forEach((fn) => {
2311
+ if (fn) rows = fn({ table: grid, rows })
2312
+ })
2313
+ } finally {
2314
+ replacedRowsByGrid.set(grid, null)
2315
+ replacedRows = null
2316
+ }
1879
2317
  cachedPipeline = options._rowModels
1880
2318
  cachedSlices = currentSlices
1881
2319
  cachedRowModelBaseRows = cachedBaseRows