@svgrid/grid 3.0.7 → 3.0.9

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 (91) hide show
  1. package/README.md +1 -1
  2. package/dist/GridMenus.svelte +3 -2
  3. package/dist/SvGrid.controller.svelte.d.ts +60 -8
  4. package/dist/SvGrid.controller.svelte.js +490 -114
  5. package/dist/SvGrid.css +186 -3
  6. package/dist/SvGrid.svelte +285 -164
  7. package/dist/SvGrid.types.d.ts +24 -7
  8. package/dist/cdn/{GridMenus-CcwU8I4t.js → GridMenus-B1sipj6y.js} +165 -165
  9. package/dist/cdn/{GridMenus-BgUS-W3o.js → GridMenus-DlAzfHLk.js} +201 -201
  10. package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
  11. package/dist/cdn/{src-kE9B98r6.js → src-BYsa66I0.js} +9747 -10178
  12. package/dist/cdn/{src-25JmJ_7g.js → src-pA4RroLV.js} +10709 -11140
  13. package/dist/cdn/svgrid.js +27 -27
  14. package/dist/cdn/svgrid.svelte-external.js +27 -27
  15. package/dist/cdn/validate-XuMe4_KR.js +80 -0
  16. package/dist/cell-render.js +25 -3
  17. package/dist/columns.js +16 -1
  18. package/dist/conditional-formatting.js +9 -2
  19. package/dist/core.d.ts +93 -0
  20. package/dist/core.js +313 -47
  21. package/dist/filtering/excel-filters.js +41 -6
  22. package/dist/headless.d.ts +1 -1
  23. package/dist/headless.js +1 -1
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +2 -1
  26. package/dist/keyboard-handlers.js +5 -1
  27. package/dist/selection.d.ts +2 -2
  28. package/dist/selection.js +46 -12
  29. package/dist/server-block-cache.d.ts +7 -32
  30. package/dist/server-block-cache.js +21 -12
  31. package/dist/server-data-source.js +3 -1
  32. package/dist/server.d.ts +1 -0
  33. package/dist/server.js +1 -0
  34. package/dist/spreadsheet.js +7 -0
  35. package/dist/{subscribe.js → subscribe.svelte.js} +5 -1
  36. package/dist/validate.js +13 -14
  37. package/dist/virtualization/column-virtualizer.d.ts +4 -0
  38. package/dist/virtualization/column-virtualizer.js +2 -0
  39. package/dist/virtualization/types.d.ts +20 -0
  40. package/dist/virtualization/virtualizer.js +90 -24
  41. package/dist/windowed-brand.d.ts +22 -0
  42. package/dist/windowed-brand.js +60 -0
  43. package/dist/windowed-data.d.ts +6 -0
  44. package/dist/windowed-data.js +34 -0
  45. package/dist/windowed-row-model.d.ts +1 -0
  46. package/dist/windowed-row-model.js +97 -0
  47. package/package.json +1 -1
  48. package/src/GridMenus.svelte +3 -2
  49. package/src/SvGrid.controller.svelte.ts +483 -122
  50. package/src/SvGrid.css +186 -3
  51. package/src/SvGrid.svelte +285 -164
  52. package/src/SvGrid.types.ts +24 -7
  53. package/src/cell-render.ts +24 -2
  54. package/src/columns.ts +16 -1
  55. package/src/conditional-formatting.ts +6 -4
  56. package/src/core.sort.test.ts +61 -0
  57. package/src/core.ts +309 -49
  58. package/src/counting-cell.test.svelte +11 -0
  59. package/src/filtering/excel-filters.ts +39 -6
  60. package/src/headless.reactivity.svelte.test.ts +89 -0
  61. package/src/headless.ts +1 -1
  62. package/src/index.ts +7 -1
  63. package/src/keyboard-handlers.ts +5 -1
  64. package/src/new-features.test.ts +24 -0
  65. package/src/selection.test.ts +51 -0
  66. package/src/selection.ts +47 -14
  67. package/src/server-block-cache.ts +27 -10
  68. package/src/server-data-source.ts +3 -1
  69. package/src/server.ts +6 -0
  70. package/src/spreadsheet.ts +6 -0
  71. package/src/{subscribe.test.ts → subscribe.svelte.test.ts} +1 -1
  72. package/src/{subscribe.ts → subscribe.svelte.ts} +5 -1
  73. package/src/svgrid.behavior.test.ts +58 -0
  74. package/src/svgrid.column-cell-recycling.test.ts +120 -0
  75. package/src/svgrid.interaction.test.ts +4 -1
  76. package/src/svgrid.new-features.wrapper.test.ts +5 -4
  77. package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
  78. package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
  79. package/src/validate.test.ts +20 -4
  80. package/src/validate.ts +13 -16
  81. package/src/virtualization/column-virtualizer.ts +6 -0
  82. package/src/virtualization/types.ts +20 -0
  83. package/src/virtualization/virtualizer.test.ts +152 -0
  84. package/src/virtualization/virtualizer.ts +93 -27
  85. package/src/windowed-brand.ts +67 -0
  86. package/src/windowed-data.test.ts +105 -0
  87. package/src/windowed-data.ts +36 -0
  88. package/src/windowed-row-model.ts +113 -0
  89. package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
  90. package/dist/cdn/validate-AZoD8BoR.js +0 -80
  91. /package/dist/{subscribe.d.ts → subscribe.svelte.d.ts} +0 -0
package/src/core.ts CHANGED
@@ -809,7 +809,7 @@ export const filterFns = {
809
809
  * One object per table, referenced by every row, instead of one closure scope
810
810
  * per row. See {@link BASE_ROW_METHODS}.
811
811
  */
812
- type BaseRowCtx<TData extends RowData> = {
812
+ export type BaseRowCtx<TData extends RowData> = {
813
813
  grid: SvGrid<TData>
814
814
  store: { state: Record<string, any> }
815
815
  columns: Array<Column<TData>>
@@ -833,25 +833,110 @@ type BaseRowCtx<TData extends RowData> = {
833
833
  * Non-enumerable string keys would have hidden them from JSON but also from the
834
834
  * spread, silently breaking every cloned row.
835
835
  */
836
- const ROW_CTX = Symbol('svgrid.row.ctx')
837
- const ROW_VALUES = Symbol('svgrid.row.values')
838
- const ROW_CELLS = Symbol('svgrid.row.cells')
836
+ // Exported for windowed-row-model.ts only; index.ts does not re-export them.
837
+ export const ROW_CTX = Symbol('svgrid.row.ctx')
838
+ export const ROW_VALUES = Symbol('svgrid.row.values')
839
+ export const ROW_CELLS = Symbol('svgrid.row.cells')
839
840
  // Where the filtered stage last put this row: the generation of that full
840
841
  // filter and the row's index in its output. Lets a tick swap a replaced row
841
842
  // 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')
843
+ export const ROW_FILTER_GEN = Symbol('svgrid.row.filterGen')
844
+ export const ROW_FILTER_POS = Symbol('svgrid.row.filterPos')
844
845
  // Stamped on the rows a sort repair drops, so the merge skips them with one
845
846
  // property read per row instead of a Set lookup (3 ms per 100k rows).
846
- const ROW_SORT_DROPPED = Symbol('svgrid.row.sortDropped')
847
+ export const ROW_SORT_DROPPED = Symbol('svgrid.row.sortDropped')
847
848
  // The key(s) the last full sort computed for a row: the key itself for a
848
849
  // one-clause sort, an array for several. Read by the repair path so a tick
849
850
  // never recomputes a kept row's key.
850
- const ROW_SORT_KEY = Symbol('svgrid.row.sortKey')
851
+ export const ROW_SORT_KEY = Symbol('svgrid.row.sortKey')
851
852
  let sortDropGeneration = 0
852
853
 
854
+ const LAZY_ROWS = Symbol('svgrid.lazyRows')
855
+ const LAZY_FILL = Symbol('svgrid.lazyRows.fill')
856
+
857
+ /**
858
+ * A real array of `length` rows whose entries are made on first read.
859
+ *
860
+ * A Proxy over a holey array: `get` fills a hole by calling `make`, and
861
+ * `has` / `getOwnPropertyDescriptor` report every index as present, so
862
+ * map, filter, slice, sort and iteration see a dense array (without them
863
+ * they would skip the holes). Writes go to the backing array.
864
+ */
865
+ function lazyRows<T>(length: number, make: (index: number) => T): T[] {
866
+ const store = new Array<T | undefined>(length)
867
+ const at = (key: string | symbol): number => {
868
+ if (typeof key !== 'string') return -1
869
+ const c = key.charCodeAt(0)
870
+ if (c < 48 || c > 57) return -1
871
+ const i = +key
872
+ return i < length && (i | 0) === i ? i : -1
873
+ }
874
+ // Make every row not made yet, in one plain loop, and return the backing
875
+ // array: dense from then on, and read without the trap.
876
+ const fill = () => {
877
+ for (let i = 0; i < length; i++) if (store[i] === undefined) store[i] = make(i)
878
+ return store as T[]
879
+ }
880
+ return new Proxy(store, {
881
+ get(t, key, receiver) {
882
+ if (key === LAZY_ROWS) return t
883
+ if (key === LAZY_FILL) return fill
884
+ const i = at(key)
885
+ if (i < 0) return Reflect.get(t, key, receiver)
886
+ let row = t[i]
887
+ if (row === undefined) t[i] = row = make(i)
888
+ return row
889
+ },
890
+ has(t, key) {
891
+ if (key === LAZY_ROWS) return true
892
+ return at(key) >= 0 || Reflect.has(t, key)
893
+ },
894
+ getOwnPropertyDescriptor(t, key) {
895
+ const i = at(key)
896
+ if (i < 0) return Reflect.getOwnPropertyDescriptor(t, key)
897
+ let row = t[i]
898
+ if (row === undefined) t[i] = row = make(i)
899
+ return { value: row, writable: true, enumerable: true, configurable: true }
900
+ },
901
+ }) as T[]
902
+ }
903
+
904
+ /**
905
+ * The rows as a plain dense array. For a lazyRows() list, makes the rows not
906
+ * made yet and returns its backing array - the same row objects, without the
907
+ * Proxy in front. Anything that reads every row (a sort, a filter) should go
908
+ * through this first: a trap per read, over several passes of a million rows,
909
+ * cost more than building the rows did.
910
+ */
911
+ export function denseRows<T>(rows: T[]): T[] {
912
+ const fill = (rows as unknown as Record<symbol, (() => T[]) | undefined>)[LAZY_FILL]
913
+ return fill ? fill() : rows
914
+ }
915
+
916
+ /** The backing array of a lazyRows() list (rows made so far, holes elsewhere), or null. */
917
+ function lazyRowsBacking<T>(rows: T[] | null): Array<T | undefined> | null {
918
+ return rows ? ((rows as unknown as Record<symbol, Array<T | undefined> | undefined>)[LAZY_ROWS] ?? null) : null
919
+ }
920
+
921
+ /**
922
+ * The row model for windowed data (windowed-data.ts), or null when `data` is
923
+ * an ordinary array. It lives in windowed-row-model.ts and is installed by the
924
+ * first createWindowedData() call, so a grid that is never handed windowed
925
+ * data does not bundle it: this file imports nothing from either module.
926
+ */
927
+ export type WindowedRowModel<TData extends RowData> = (data: unknown, columns: Array<Column<TData>>) => RowModel<TData> | null
928
+ export type WindowedRowModelInstall = <TData extends RowData>(ctx: {
929
+ rowCtxFor: (columns: Array<Column<TData>>) => BaseRowCtx<TData>
930
+ getRowId: () => ((row: TData, index: number) => string) | undefined
931
+ }) => WindowedRowModel<TData>
932
+ let installWindowedRowModel: WindowedRowModelInstall | null = null
933
+ /** Internal: windowed-row-model.ts installs itself here. */
934
+ export function registerWindowedRowModel(install: WindowedRowModelInstall): void {
935
+ installWindowedRowModel = install
936
+ }
937
+
853
938
  /** A base row's private fields, on top of the public {@link Row} surface. */
854
- type BaseRowState<TData extends RowData> = Row<TData> & {
939
+ export type BaseRowState<TData extends RowData> = Row<TData> & {
855
940
  [ROW_CTX]: BaseRowCtx<TData>
856
941
  [ROW_VALUES]: Array<unknown> | null
857
942
  [ROW_CELLS]: Array<Cell<TData>> | null
@@ -876,7 +961,7 @@ type BaseRowState<TData extends RowData> = Row<TData> & {
876
961
  * `{ ...row, depth }`, and a spread copies own properties but not a prototype.
877
962
  * A class here would silently strip every method off a cloned row.
878
963
  */
879
- const BASE_ROW_METHODS = {
964
+ export const BASE_ROW_METHODS = {
880
965
  getCanExpand(this: BaseRowState<RowData>) {
881
966
  return false
882
967
  },
@@ -1456,6 +1541,8 @@ export function createSortedRowModel<TData extends RowData>(
1456
1541
  desc: boolean
1457
1542
  compare: (a: any, b: any) => number
1458
1543
  keyOf: (row: Row<TData>) => any
1544
+ /** Set when every key is an integer rank in [0, rankCount): the text rank path. */
1545
+ rankCount?: number
1459
1546
  }> = []
1460
1547
 
1461
1548
  for (const clause of sorting) {
@@ -1486,6 +1573,7 @@ export function createSortedRowModel<TData extends RowData>(
1486
1573
  let compare: (a: any, b: any) => number
1487
1574
  let keyOf: (row: Row<TData>) => any
1488
1575
  let autoMode: 'number' | 'mixed' | 'text' | undefined
1576
+ let rankCount: number | undefined
1489
1577
 
1490
1578
  if (comparator === sortFns.number) {
1491
1579
  keyOf = (row) => numberSortKey(row.getCellValueByColumnId(columnId))
@@ -1529,9 +1617,20 @@ export function createSortedRowModel<TData extends RowData>(
1529
1617
  // column. Reading every k-th row is no more expensive and does not care
1530
1618
  // how the rows are arranged.
1531
1619
  const rankLimit = n >> 1
1532
- let distinct: Set<string> | null = null
1620
+ // Each distinct value's id (its insertion order), when ranking.
1621
+ let idOf: Map<string, number> | null = null
1533
1622
  if (n > 0) {
1534
- const sampleTarget = Math.min(n, 256)
1623
+ // Ranking pays when the column has at most n/2 distinct values. A
1624
+ // small sample cannot see that by asking whether half of it repeats:
1625
+ // 1M rows of 80k values, each one 12 times over, gave 256 samples
1626
+ // with almost no repeats, so ranking was skipped and the sort made
1627
+ // 20M collator calls (1.75 s; ranked, it is a fraction of that).
1628
+ // Count the repeats in a larger sample instead. S samples from u
1629
+ // equally common values repeat about S^2 / 2u times, so u <= n/2
1630
+ // shows as at least S^2 / n repeats. Up to SAMPLE rows the whole
1631
+ // column is the sample and the test is exact.
1632
+ const SAMPLE = 2048
1633
+ const sampleTarget = Math.min(n, SAMPLE)
1535
1634
  const stride = Math.max(1, Math.floor(n / sampleTarget))
1536
1635
  const sample = new Set<string>()
1537
1636
  let sampled = 0
@@ -1539,16 +1638,30 @@ export function createSortedRowModel<TData extends RowData>(
1539
1638
  sample.add(autoTextKey(rows[i]!.getCellValueByColumnId(columnId)))
1540
1639
  sampled++
1541
1640
  }
1542
- // Only attempt ranking when the sample suggests real repetition.
1543
- if (sample.size * 2 <= sampled) distinct = new Set()
1641
+ const repeats = sampled - sample.size
1642
+ const worthRanking = stride === 1 ? sample.size <= rankLimit : repeats * n >= sampled * sampled
1643
+ if (worthRanking) idOf = new Map()
1544
1644
  }
1545
1645
 
1646
+ // One hash per row: the value's id is looked up (or assigned) as the
1647
+ // row is read, and the rank lookup further down is an array read by
1648
+ // id. It used to add every value to a Set and then look every value up
1649
+ // again in a rank Map, two hashes per row.
1650
+ const ids: Int32Array | null = idOf ? new Int32Array(n) : null
1546
1651
  for (let i = 0; i < n; i++) {
1547
1652
  const s = autoTextKey(rows[i]!.getCellValueByColumnId(columnId))
1548
1653
  strings[i] = s
1549
- if (distinct) {
1550
- distinct.add(s)
1551
- if (distinct.size > rankLimit) distinct = null
1654
+ if (idOf) {
1655
+ let id = idOf.get(s)
1656
+ if (id === undefined) {
1657
+ id = idOf.size
1658
+ idOf.set(s, id)
1659
+ if (idOf.size > rankLimit) {
1660
+ idOf = null
1661
+ continue
1662
+ }
1663
+ }
1664
+ ids![i] = id
1552
1665
  }
1553
1666
  }
1554
1667
 
@@ -1565,11 +1678,15 @@ export function createSortedRowModel<TData extends RowData>(
1565
1678
  // Guarded on the uniqueness ratio: when nearly every value is distinct
1566
1679
  // the ranking pass cannot save any collator calls and would just add an
1567
1680
  // O(n) Map build, so that case keeps comparing directly.
1568
- if (distinct) {
1569
- const ordered = Array.from(distinct).sort(compareCollatedKeys)
1570
- const rankOf = new Map<string, number>()
1571
- for (let i = 0; i < ordered.length; i++) rankOf.set(ordered[i]!, i)
1572
- for (let i = 0; i < n; i++) keys[i] = rankOf.get(strings[i]!)!
1681
+ if (idOf) {
1682
+ // Map keys come back in insertion order, which is id order.
1683
+ const values = Array.from(idOf.keys())
1684
+ const byRank = values.map((_, id) => id).sort((a, b) => compareCollatedKeys(values[a]!, values[b]!))
1685
+ const rankById = new Int32Array(values.length)
1686
+ for (let r = 0; r < byRank.length; r++) rankById[byRank[r]!] = r
1687
+ for (let i = 0; i < n; i++) keys[i] = rankById[ids![i]!]!
1688
+ const rankIds = idOf
1689
+ rankCount = values.length
1573
1690
  compare = compareNumericKeys
1574
1691
  // A replacement whose text is not among the ranked values has no
1575
1692
  // rank; the repair path treats that as "sort everything again".
@@ -1577,7 +1694,8 @@ export function createSortedRowModel<TData extends RowData>(
1577
1694
  const value = row.getCellValueByColumnId(columnId)
1578
1695
  // A number arriving in a tick makes the column mixed; resort fully.
1579
1696
  if (typeof value === 'number') return MISSING_KEY
1580
- return rankOf.get(autoTextKey(value)) ?? MISSING_KEY
1697
+ const id = rankIds.get(autoTextKey(value))
1698
+ return id === undefined ? MISSING_KEY : rankById[id]!
1581
1699
  }
1582
1700
  } else {
1583
1701
  keys = strings
@@ -1593,7 +1711,7 @@ export function createSortedRowModel<TData extends RowData>(
1593
1711
  compare = comparator
1594
1712
  }
1595
1713
 
1596
- clauses.push({ keys, desc: clause.desc, compare, keyOf })
1714
+ clauses.push({ keys, desc: clause.desc, compare, keyOf, rankCount })
1597
1715
  }
1598
1716
 
1599
1717
  if (!clauses.length) return null
@@ -1601,8 +1719,10 @@ export function createSortedRowModel<TData extends RowData>(
1601
1719
  // Sort an index array, then materialise. `Array.prototype.sort` is stable,
1602
1720
  // so equal keys keep their original relative order exactly as the previous
1603
1721
  // `[...rows].sort(...)` did.
1604
- const order = new Array<number>(rows.length)
1605
- for (let i = 0; i < order.length; i++) order[i] = i
1722
+ // An index array the comparator sorts in place, or a typed array from
1723
+ // countingOrder / radixOrder for a large one-column numeric sort.
1724
+ let order: ArrayLike<number> = new Array<number>(rows.length)
1725
+ for (let i = 0; i < order.length; i++) (order as number[])[i] = i
1606
1726
 
1607
1727
  // Single-clause sorts get a specialised comparator.
1608
1728
  //
@@ -1618,22 +1738,26 @@ export function createSortedRowModel<TData extends RowData>(
1618
1738
  // which the spec coerces to 0, and they differ only in producing 0 versus
1619
1739
  // -0 for equal keys, which sorts identically.
1620
1740
  if (clauses.length === 1) {
1621
- const { keys, compare, desc } = clauses[0]!
1622
- if (compare === compareNumericKeys) {
1623
- order.sort(
1741
+ const { keys, compare, desc, rankCount } = clauses[0]!
1742
+ if (rankCount !== undefined && rows.length >= STABLE_NUMERIC_SORT_MIN) {
1743
+ order = countingOrder(keys as number[], rankCount, desc)
1744
+ } else if (compare === compareNumericKeys && rows.length >= STABLE_NUMERIC_SORT_MIN) {
1745
+ order = radixOrder(keys as number[], desc)
1746
+ } else if (compare === compareNumericKeys) {
1747
+ ;(order as number[]).sort(
1624
1748
  desc
1625
1749
  ? function compareOneNumericDesc(ia, ib) { return keys[ib] - keys[ia] }
1626
1750
  : function compareOneNumericAsc(ia, ib) { return keys[ia] - keys[ib] },
1627
1751
  )
1628
1752
  } else {
1629
- order.sort(
1753
+ ;(order as number[]).sort(
1630
1754
  desc
1631
1755
  ? function compareOneDesc(ia, ib) { return -compare(keys[ia], keys[ib]) }
1632
1756
  : function compareOneAsc(ia, ib) { return compare(keys[ia], keys[ib]) },
1633
1757
  )
1634
1758
  }
1635
1759
  } else {
1636
- order.sort(function compareRowsByClauses(ia, ib) {
1760
+ ;(order as number[]).sort(function compareRowsByClauses(ia, ib) {
1637
1761
  for (let k = 0; k < clauses.length; k++) {
1638
1762
  const clause = clauses[k]!
1639
1763
  const result = clause.compare(clause.keys[ia], clause.keys[ib])
@@ -1850,6 +1974,90 @@ function autoSortMode(rows: ReadonlyArray<Row<any>>, columnId: string): 'number'
1850
1974
  * Left as its own function so the sort path has one place to change if that
1851
1975
  * ever stops being true. Re-measure before "optimising" this again.
1852
1976
  */
1977
+ /**
1978
+ * From this many rows, a one-column numeric sort orders its row indices
1979
+ * without a comparator. `Array.prototype.sort` with a JS comparator made
1980
+ * ~20M comparator calls for 1M rows, most of a numeric sort's time; both
1981
+ * orders below are stable, as that sort is, so equal keys keep input order.
1982
+ * Under it the comparator sort is cheaper than the bucket arrays.
1983
+ */
1984
+ const STABLE_NUMERIC_SORT_MIN = 20_000
1985
+
1986
+ /** Stable order of `keys` that are integer ranks in [0, rankCount): one counting pass. */
1987
+ function countingOrder(keys: ArrayLike<number>, rankCount: number, desc: boolean): Uint32Array {
1988
+ const n = keys.length
1989
+ const starts = new Uint32Array(rankCount + 1)
1990
+ for (let i = 0; i < n; i++) starts[(desc ? rankCount - 1 - keys[i]! : keys[i]!) + 1]!++
1991
+ for (let r = 0; r < rankCount; r++) starts[r + 1]! += starts[r]!
1992
+ const out = new Uint32Array(n)
1993
+ for (let i = 0; i < n; i++) out[starts[desc ? rankCount - 1 - keys[i]! : keys[i]!]!++] = i
1994
+ return out
1995
+ }
1996
+
1997
+ const LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1
1998
+
1999
+ /**
2000
+ * Stable order of float keys: an LSD radix sort over the IEEE bits, four
2001
+ * 16-bit passes, a pass skipped when every key shares its digit. The bits are
2002
+ * mapped so unsigned order is numeric order (sign bit flipped for positives,
2003
+ * every bit for negatives), and inverted for a descending sort, which keeps
2004
+ * equal keys in input order exactly as a descending comparator does. -0 is
2005
+ * folded into 0 first, since the comparator treated them as equal. Keys are
2006
+ * never NaN here: numberSortKey and dateSortKey map those to -Infinity.
2007
+ */
2008
+ function radixOrder(keys: ArrayLike<number>, desc: boolean): Uint32Array {
2009
+ const n = keys.length
2010
+ const f64 = new Float64Array(n)
2011
+ for (let i = 0; i < n; i++) f64[i] = keys[i]! + 0
2012
+ const words = new Uint32Array(f64.buffer)
2013
+ const lo = new Uint32Array(n)
2014
+ const hi = new Uint32Array(n)
2015
+ const loAt = LITTLE_ENDIAN ? 0 : 1
2016
+ const hiAt = 1 - loAt
2017
+ for (let i = 0; i < n; i++) {
2018
+ let h = words[2 * i + hiAt]!
2019
+ let l = words[2 * i + loAt]!
2020
+ if (h & 0x80000000) {
2021
+ h = ~h >>> 0
2022
+ l = ~l >>> 0
2023
+ } else {
2024
+ h = (h | 0x80000000) >>> 0
2025
+ }
2026
+ if (desc) {
2027
+ h = ~h >>> 0
2028
+ l = ~l >>> 0
2029
+ }
2030
+ hi[i] = h
2031
+ lo[i] = l
2032
+ }
2033
+ let src = new Uint32Array(n)
2034
+ for (let i = 0; i < n; i++) src[i] = i
2035
+ let dst = new Uint32Array(n)
2036
+ const counts = new Uint32Array(65536)
2037
+ for (let pass = 0; pass < 4; pass++) {
2038
+ const word = pass < 2 ? lo : hi
2039
+ const shift = pass % 2 === 0 ? 0 : 16
2040
+ counts.fill(0)
2041
+ for (let i = 0; i < n; i++) counts[(word[i]! >>> shift) & 0xffff]!++
2042
+ // Every key in one bucket: this digit cannot change the order.
2043
+ if (counts[(word[src[0]!]! >>> shift) & 0xffff] === n) continue
2044
+ let sum = 0
2045
+ for (let d = 0; d < 65536; d++) {
2046
+ const c = counts[d]!
2047
+ counts[d] = sum
2048
+ sum += c
2049
+ }
2050
+ for (let i = 0; i < n; i++) {
2051
+ const idx = src[i]!
2052
+ dst[counts[(word[idx]! >>> shift) & 0xffff]!++] = idx
2053
+ }
2054
+ const t = src
2055
+ src = dst
2056
+ dst = t
2057
+ }
2058
+ return src
2059
+ }
2060
+
1853
2061
  function compareCollatedKeys(a: string, b: string): number {
1854
2062
  return a.localeCompare(b)
1855
2063
  }
@@ -1953,6 +2161,24 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1953
2161
  /** Old row -> new row for the rebuild the next pipeline run follows; see `replacedRowsOf`. */
1954
2162
  let replacedRows: Map<Row<TData>, Row<TData>> | null = null
1955
2163
  let cachedRowCtx: BaseRowCtx<TData> | null = null
2164
+ /** This grid's windowed-data row model, made on first use from the installed hook. */
2165
+ let windowedRowModel: WindowedRowModel<TData> | null = null
2166
+
2167
+ /** The shared row context for `columns`, reused while they are the same. */
2168
+ function rowCtxFor(columns: Array<Column<TData>>): BaseRowCtx<TData> {
2169
+ if (cachedRowCtx && cachedRowCtx.columns === columns) return cachedRowCtx
2170
+ const columnIndexById = new Map<string, number>()
2171
+ for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
2172
+ cachedRowCtx = {
2173
+ grid: grid as SvGrid<TData>,
2174
+ store,
2175
+ columns,
2176
+ columnCount: columns.length,
2177
+ columnIndexById,
2178
+ }
2179
+ return cachedRowCtx
2180
+ }
2181
+
1956
2182
  let cachedRowModel: RowModel<TData> | null = null
1957
2183
  let cachedRowModelBaseRows: Array<Row<TData>> | null = null
1958
2184
  let cachedPipeline = options._rowModels
@@ -2165,6 +2391,11 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
2165
2391
  // (a $state.raw signal), and the rebuild below touched it twice per row:
2166
2392
  // 200k tracked reads per 100k-row tick, 11 ms of a 65 ms tick.
2167
2393
  const data = options.data
2394
+ if (installWindowedRowModel) {
2395
+ windowedRowModel ??= installWindowedRowModel<TData>({ rowCtxFor, getRowId: () => options.getRowId })
2396
+ const model = windowedRowModel(data, columns)
2397
+ if (model) return model
2398
+ }
2168
2399
  if (cachedBaseRowsInput !== data || cachedBaseRowsColumns !== columns) {
2169
2400
  // Same columns as last time: the shared context still describes them,
2170
2401
  // and a row whose data object sits at the same index can keep its
@@ -2174,7 +2405,10 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
2174
2405
  // landing. The reused row drops its memoised values and cells, since
2175
2406
  // an app may have changed the object in place before passing a new
2176
2407
  // array - that is the case the old rebuild covered by accident.
2177
- const previous = cachedBaseRowsColumns === columns && cachedRowCtx ? cachedBaseRows : null
2408
+ // A lazy row list (lazyRows below) is read through its backing array,
2409
+ // so reusing rows never makes the ones nobody looked at.
2410
+ const previous = cachedBaseRowsColumns === columns && cachedRowCtx ? (lazyRowsBacking(cachedBaseRows) ?? cachedBaseRows) : null
2411
+ const previousInput = cachedBaseRowsInput
2178
2412
  // Same length as last time: a replacement, not an add or remove, so
2179
2413
  // each new row stands in for the old row at its index and the stages
2180
2414
  // can repair rather than recompute. Anything else is structural.
@@ -2201,7 +2435,6 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
2201
2435
  cachedRowCtx = rowCtx
2202
2436
  }
2203
2437
 
2204
- cachedBaseRows = new Array(data.length)
2205
2438
  const getRowId = options.getRowId
2206
2439
  const m = BASE_ROW_METHODS as unknown as {
2207
2440
  getCanExpand: Row<TData>['getCanExpand']
@@ -2212,22 +2445,12 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
2212
2445
  getAllCells: Row<TData>['getAllCells']
2213
2446
  getCellValueByColumnId: Row<TData>['getCellValueByColumnId']
2214
2447
  }
2215
- for (let index = 0; index < data.length; index++) {
2448
+ // `_values` and `_cells` stay null until something reads them - a
2449
+ // 100k-row grid showing twenty rows must not materialise every row's
2450
+ // values or cell objects to paint.
2451
+ const makeRow = (index: number): BaseRowState<TData> => {
2216
2452
  const original = data[index]!
2217
- const kept = previous?.[index] as BaseRowState<TData> | undefined
2218
- if (kept && kept.original === original) {
2219
- kept[ROW_VALUES] = null
2220
- kept[ROW_CELLS] = null
2221
- cachedBaseRows[index] = kept
2222
- continue
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
2227
- // `_values` and `_cells` stay null until something reads them - a
2228
- // 100k-row grid showing twenty rows must not materialise every row's
2229
- // values or cell objects to paint.
2230
- const row: BaseRowState<TData> = {
2453
+ return {
2231
2454
  id: getRowId ? getRowId(original, index) : String(index),
2232
2455
  index,
2233
2456
  original,
@@ -2247,6 +2470,36 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
2247
2470
  getAllCells: m.getAllCells,
2248
2471
  getCellValueByColumnId: m.getCellValueByColumnId,
2249
2472
  }
2473
+ }
2474
+ if (!previous) {
2475
+ // A first build has nothing to reuse or repair: make each row the
2476
+ // first time something reads it. A grid that only shows the first
2477
+ // screen makes ~40 row objects, not 100k; a sort or a filter reads
2478
+ // every row and makes them then. Building them all up front was
2479
+ // most of a 100k-row mount (~24 ms, plus the garbage).
2480
+ cachedBaseRows = lazyRows(data.length, makeRow)
2481
+ } else cachedBaseRows = new Array(data.length)
2482
+ for (let index = 0; previous && index < data.length; index++) {
2483
+ const original = data[index]!
2484
+ const kept = previous[index] as BaseRowState<TData> | undefined
2485
+ if (kept && kept.original === original) {
2486
+ // Read before writing: a row nobody rendered (a million off-screen
2487
+ // placeholders under a row model) has nothing memoised, and a
2488
+ // store per row was a measurable share of each block landing.
2489
+ if (kept[ROW_VALUES] !== null) kept[ROW_VALUES] = null
2490
+ if (kept[ROW_CELLS] !== null) kept[ROW_CELLS] = null
2491
+ cachedBaseRows[index] = kept
2492
+ continue
2493
+ }
2494
+ // Past a quarter of the rows the merge stops being cheaper than a
2495
+ // sort with the ranking pass; let the stages do the full work.
2496
+ if (replacedRows && replacedRows.size * 4 >= data.length) replacedRows = null
2497
+ const row = makeRow(index)
2498
+ // The previous list was lazy and never made this row, but its data
2499
+ // object is the same one: hand the old list this row too, so a
2500
+ // caller still holding it gets the same object for the same data,
2501
+ // as when every row was built up front.
2502
+ if (kept === undefined && previousInput?.[index] === original) previous[index] = row
2250
2503
  cachedBaseRows[index] = row
2251
2504
  if (replacedRows && kept) replacedRows.set(kept, row)
2252
2505
  }
@@ -2292,7 +2545,14 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
2292
2545
  return cachedRowModel
2293
2546
  }
2294
2547
 
2295
- let rows: Array<Row<TData>> = cachedBaseRows
2548
+ // A stage with work to do reads every row; give it the dense array.
2549
+ const anyStageActive =
2550
+ (currentSlices.sorting?.length ?? 0) > 0 ||
2551
+ (currentSlices.columnFilters?.length ?? 0) > 0 ||
2552
+ (currentSlices.grouping?.length ?? 0) > 0 ||
2553
+ (currentSlices.expanded as unknown) === true ||
2554
+ Object.keys(currentSlices.expanded ?? {}).length > 0
2555
+ let rows: Array<Row<TData>> = anyStageActive ? denseRows(cachedBaseRows) : cachedBaseRows
2296
2556
 
2297
2557
  const pipeline = options._rowModels ?? {}
2298
2558
  const ordered: Array<RowModelFactory<TData> | undefined> = [
@@ -0,0 +1,11 @@
1
+ <script lang="ts">
2
+ // Test cell (svgrid.column-cell-recycling.test.ts): records the column it
3
+ // was created for, so a test can tell a re-created instance from one that
4
+ // was handed another column's props.
5
+ import { untrack } from 'svelte'
6
+
7
+ let { columnId, value }: { columnId: string; value: unknown } = $props()
8
+ const createdFor = untrack(() => columnId)
9
+ </script>
10
+
11
+ <span data-counting-cell data-created-for={createdFor}>{String(value)}</span>
@@ -207,6 +207,39 @@ export {
207
207
  /** A filter with its needle-side work already done. Call per row. */
208
208
  export type CompiledExcelFilter = (cellValue: unknown) => boolean
209
209
 
210
+ /**
211
+ * A text test, remembered per distinct cell text.
212
+ *
213
+ * The text operators fold every cell (lowercase, and for non-ASCII text NFD
214
+ * plus diacritic stripping) before comparing, and a column the user filters
215
+ * is usually a category - region, status, owner - with a handful of distinct
216
+ * values over every row. Folding each row again was most of a 1M-row filter
217
+ * (417 ms). The answer depends only on the cell's text, so it is kept per
218
+ * text. A column that turns out mostly unique (more than half of the first
219
+ * 2,048 lookups missed) stops remembering and tests directly, and the memo
220
+ * never grows past MEMO_CAP entries.
221
+ */
222
+ const MEMO_CAP = 16_384
223
+ function memoText(test: (s: string) => boolean): CompiledExcelFilter {
224
+ let memo: Map<string, boolean> | null = new Map()
225
+ let lookups = 0
226
+ let hits = 0
227
+ return (cellValue) => {
228
+ const s = typeof cellValue === 'string' ? cellValue : String(cellValue ?? '')
229
+ if (memo === null) return test(s)
230
+ lookups++
231
+ const known = memo.get(s)
232
+ if (known !== undefined) {
233
+ hits++
234
+ return known
235
+ }
236
+ const result = test(s)
237
+ if (memo.size < MEMO_CAP) memo.set(s, result)
238
+ if (lookups >= 2048 && hits * 2 < lookups) memo = null
239
+ return result
240
+ }
241
+ }
242
+
210
243
  /**
211
244
  * Compile a filter once, then test many rows against it.
212
245
  *
@@ -228,21 +261,21 @@ export function compileExcelFilter(
228
261
 
229
262
  switch (filter.operator) {
230
263
  case 'contains':
231
- return (cellValue) => fold(cellValue).includes(needle)
264
+ return memoText((s) => fold(s).includes(needle))
232
265
  case 'notContains':
233
266
  // An empty needle is no constraint (mirrors `contains` returning true),
234
267
  // so nothing is excluded until the user types something.
235
268
  if (needle === '') return () => true
236
- return (cellValue) => !fold(cellValue).includes(needle)
269
+ return memoText((s) => !fold(s).includes(needle))
237
270
  case 'equals':
238
- return (cellValue) => fold(cellValue) === needle
271
+ return memoText((s) => fold(s) === needle)
239
272
  case 'notEquals':
240
273
  if (needle === '') return () => true
241
- return (cellValue) => fold(cellValue) !== needle
274
+ return memoText((s) => fold(s) !== needle)
242
275
  case 'startsWith':
243
- return (cellValue) => fold(cellValue).startsWith(needle)
276
+ return memoText((s) => fold(s).startsWith(needle))
244
277
  case 'endsWith':
245
- return (cellValue) => fold(cellValue).endsWith(needle)
278
+ return memoText((s) => fold(s).endsWith(needle))
246
279
  case 'regex': {
247
280
  // Case-insensitive by default (matches the accent/case-folded feel of
248
281
  // the other text operators). An invalid pattern matches nothing rather