@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
@@ -38,6 +38,9 @@ import {
38
38
  resolveMaxDomHeight,
39
39
  } from "./virtualization/scroll-scaling";
40
40
  import { buildPreMeasureItems } from "./virtualization/virtualizer";
41
+ // Not on the public barrel: turns a lazy row list into a plain array before a full pass.
42
+ import { denseRows } from "./core";
43
+ import { isWindowedData } from "./windowed-brand";
41
44
  import { DEV } from "esm-env";
42
45
  import "./sv-grid-scrollbar";
43
46
  import {
@@ -151,6 +154,14 @@ import { untrack } from "svelte";
151
154
  */
152
155
  const MAX_DOM_SCROLL_HEIGHT_FALLBACK = 8_000_000;
153
156
 
157
+ /** Columns the column window moves by during a horizontal scroll. */
158
+ const COLUMN_WINDOW_STEP = 3;
159
+ /** The default `columnOverscan`. A margin of 2 measured 6% less scroll work,
160
+ * but the window at rest is `1 + columnOverscan` columns wide wherever the
161
+ * viewport measures 0 px - jsdom, where apps test their grids - and 2 left
162
+ * the fourth column out of tests that read it. */
163
+ const COLUMN_OVERSCAN = 3;
164
+
154
165
  /**
155
166
  * The browser's actual maximum *scrollable* element height in CSS px. Browsers
156
167
  * clamp how tall a single element may be, and the cap is lower on mobile /
@@ -301,6 +312,12 @@ export function createSvGridController<
301
312
  */
302
313
  const gridDomId = domIdBase ?? "svgrid";
303
314
 
315
+ // Detect the browser's element-height cap now, while this grid has no DOM.
316
+ // The probe reads offsetHeight, which lays out the whole document; run
317
+ // mid-render it laid out this grid's first frame too, and on a wide grid
318
+ // that one layout was seconds. Cached, so later calls are free.
319
+ if (typeof document !== "undefined") getMaxDomScrollHeight();
320
+
304
321
  // Runtime option overrides set via the imperative api (`api.setOption(key, value)`).
305
322
  // Every controller read of a prop goes through the `props` proxy below (and the view
306
323
  // reads controller-resolved gates like `_features` / `editingEnabled`, not the raw
@@ -572,7 +589,11 @@ export function createSvGridController<
572
589
  /** When an edit starts: true selects all text, false places the caret at the end. */
573
590
  let editorSelectAll = true;
574
591
  /** Per-column width overrides set by the resize handles. */
575
- let columnWidths = $state<Record<string, number>>({});
592
+ // Raw, like collapsedColumns and hiddenColumns below: every writer replaces
593
+ // the object, and a deep proxy allocated a reactive source for each column
594
+ // id anything asked about - every column, on every width rebuild, on a
595
+ // 10,000-column grid.
596
+ let columnWidths = $state.raw<Record<string, number>>({});
576
597
  /**
577
598
  * Columns folded to nothing, the way a sheet hides a column: the column
578
599
  * keeps its index, its cells and its width for later, and takes no room.
@@ -580,7 +601,7 @@ export function createSvGridController<
580
601
  * and shifts every index after it; a spreadsheet's formulas and formats
581
602
  * are keyed by index and could not follow that.
582
603
  */
583
- let collapsedColumns = $state<Record<string, boolean>>({});
604
+ let collapsedColumns = $state.raw<Record<string, boolean>>({});
584
605
  function setColumnCollapsed(columnId: string, collapsed: boolean): void {
585
606
  if (!!collapsedColumns[columnId] === collapsed) return;
586
607
  if (collapsed) {
@@ -666,7 +687,10 @@ export function createSvGridController<
666
687
  let contextMenuPos = $state<MenuPosition>({ x: 0, y: 0 });
667
688
  // Editable comments: internal overlay (rowId -> columnId -> note) merged on
668
689
  // top of props.notes for immediate feedback, plus the open-editor state.
669
- let noteOverrides = $state<Record<string, Record<string, string>>>({});
690
+ // Raw: writeNote replaces the object (and the row map) on every edit, and a
691
+ // deep proxy made every cell that asked about a note it does not have
692
+ // allocate a reactive source for that row id (0.3 ms of a far jump).
693
+ let noteOverrides = $state.raw<Record<string, Record<string, string>>>({});
670
694
  let commentEditFor = $state<{ rowId: string; columnId: string; x: number; y: number } | null>(null);
671
695
  let commentDraft = $state("");
672
696
  let valueFilters = $state<Record<string, Set<string>>>({});
@@ -727,18 +751,20 @@ export function createSvGridController<
727
751
  scrollHeight: 0,
728
752
  scrollWidth: 0,
729
753
  });
730
- $effect(function scrollMetrics_e() {
731
- scrollVersion;
732
- viewportVersion;
733
- virtualizer.version;
734
- columnVirtualizerVersion;
754
+ // The four reads below need a clean layout. This effect runs right after a
755
+ // scroll flush has written the new rows and columns, so reading here forced
756
+ // a synchronous layout on every scroll frame (~8 ms a frame on a 10,000-
757
+ // column grid). Instead the read waits for the start of the next frame,
758
+ // when the browser has already laid out and painted; triggers in between
759
+ // collapse into that one read.
760
+ function readScrollMetrics() {
735
761
  const el = scrollContainer;
736
762
  if (!el) return;
737
763
  const scrollTop = el.scrollTop;
738
764
  const scrollLeft = el.scrollLeft;
739
765
  const scrollHeight = el.scrollHeight;
740
766
  const scrollWidth = el.scrollWidth;
741
- const prev = untrack(() => scrollMetrics);
767
+ const prev = scrollMetrics;
742
768
  if (
743
769
  prev.scrollTop === scrollTop &&
744
770
  prev.scrollLeft === scrollLeft &&
@@ -748,6 +774,19 @@ export function createSvGridController<
748
774
  return;
749
775
  }
750
776
  scrollMetrics = { scrollTop, scrollLeft, scrollHeight, scrollWidth };
777
+ }
778
+ $effect(function scrollMetrics_e() {
779
+ scrollVersion;
780
+ viewportVersion;
781
+ virtualizer.version;
782
+ columnVirtualizerVersion;
783
+ if (!scrollContainer) return;
784
+ if (typeof requestAnimationFrame !== "function") {
785
+ untrack(readScrollMetrics);
786
+ return;
787
+ }
788
+ const raf = requestAnimationFrame(readScrollMetrics);
789
+ return () => cancelAnimationFrame(raf);
751
790
  });
752
791
  /** Vertical overflow from the virtualizer's authoritative total size,
753
792
  * NOT from `scrollMetrics.scrollHeight` alone. Reading DOM dimensions
@@ -868,7 +907,7 @@ export function createSvGridController<
868
907
  resolveCols(props.columns),
869
908
  );
870
909
  // svelte-ignore state_referenced_locally
871
- let hiddenColumns = $state<Record<string, boolean>>(
910
+ let hiddenColumns = $state.raw<Record<string, boolean>>(
872
911
  initialHiddenColumns(props.columns),
873
912
  );
874
913
  // The columns declared `visible: false` in the ColumnDefs. Kept separately so
@@ -918,7 +957,9 @@ export function createSvGridController<
918
957
  // A fresh array object, because `getRowModel` memoises on identity. The
919
958
  // elements are the same objects, so a row that did not move keeps its row
920
959
  // object and a field write still reaches its cell through the proxy.
921
- internalData = length === 0 ? EMPTY_DATA : next.slice();
960
+ // Windowed data (a server row model's rows) is already a fresh array per
961
+ // change and copying it would read every one of its `length` entries.
962
+ internalData = length === 0 ? EMPTY_DATA : isWindowedData(next) ? next : next.slice();
922
963
  // When the consumer replaces `data` (e.g. a "Reset" button), drop any
923
964
  // accumulated cell-edit overrides - otherwise `getCellDisplayValue`
924
965
  // would keep returning the old edited values from `editedCellValues`
@@ -1217,16 +1258,28 @@ export function createSvGridController<
1217
1258
  }
1218
1259
 
1219
1260
  const allColumns = $derived.by(function allColumns_d() {
1261
+ // Read the hidden set and the responsive flag once, not once per column.
1262
+ // `hiddenColumns` is a deep proxy: asking it about an id it does not
1263
+ // hold allocated a reactive source for that id, 10,000 of them on a
1264
+ // 10,000-column mount, and `props.responsive` is a reactive read too.
1265
+ // Iterating the proxy's own keys tracks additions with one dependency.
1266
+ const hiddenIds = new Set<string>();
1267
+ for (const id in hiddenColumns) if (hiddenColumns[id]) hiddenIds.add(id);
1268
+ const responsiveOn = !!props.responsive;
1269
+ // Derived values read once too: a read inside the filter is a tracked
1270
+ // get per column.
1271
+ const groupHidden = hiddenByGroupCollapse;
1272
+ const foldedSources = autoGroupSpec.hiddenSourceIds;
1220
1273
  let raw = grid
1221
1274
  .getAllColumns()
1222
1275
  .filter(
1223
1276
  (column) =>
1224
- !hiddenColumns[column.id] &&
1225
- !hiddenByGroupCollapse[column.id] &&
1226
- !isHiddenByResponsive(column) &&
1277
+ !hiddenIds.has(column.id) &&
1278
+ !groupHidden[column.id] &&
1279
+ !(responsiveOn && isHiddenByResponsive(column)) &&
1227
1280
  // In a column display mode the grouped columns are folded into the
1228
1281
  // auto column(s), so showing them too would just duplicate the value.
1229
- !autoGroupSpec.hiddenSourceIds.has(column.id),
1282
+ !foldedSources.has(column.id),
1230
1283
  );
1231
1284
  // Auto-group columns lead, like a row header.
1232
1285
  if (autoGroupColumns.length) raw = [...autoGroupColumns, ...raw];
@@ -1319,6 +1372,10 @@ export function createSvGridController<
1319
1372
  groupId?: string;
1320
1373
  collapsible: boolean;
1321
1374
  collapsed: boolean;
1375
+ /** Width of the unrendered columns before this cell when column
1376
+ * virtualization splits the window into pinned and centre runs; the
1377
+ * template draws a spacer of this width first. */
1378
+ gapBefore?: number;
1322
1379
  };
1323
1380
  const groupHeaderRows = $derived.by(function groupHeaderRows_d() {
1324
1381
  const userCols: Array<ColumnDef<any, TData>> =
@@ -1509,6 +1566,10 @@ export function createSvGridController<
1509
1566
  const map = new Map<string, ColumnStat>();
1510
1567
  const formats = props.conditionalFormats;
1511
1568
  if (!formats?.length || !formatsNeedingStats(formats)) return map;
1569
+ // Windowed data (a server row model) is mostly rows nobody has loaded;
1570
+ // its range is the server's to know. Scanning it would read every row of
1571
+ // the source. Give such formats `minValue` / `maxValue` instead.
1572
+ if (isWindowedData(internalData)) return map;
1512
1573
  // `filtered` (default): every row that survives the filters, ignoring the
1513
1574
  // page slice - so a value keeps the same colour as you page through (#61).
1514
1575
  // `visible`: only the rows on screen, rescaling per page. `all`: the full
@@ -1653,16 +1714,32 @@ export function createSvGridController<
1653
1714
  ? compileCond(columnId, f.operator2 as FilterOperator, f.value2 ?? "", f.valueTo2)
1654
1715
  : null,
1655
1716
  }));
1656
- rows = rows.filter((row) =>
1657
- compiledMenuFilters.every(({ readValue, join, a, b }) => {
1658
- const cellValue = readValue(row);
1659
- const ra = a ? a(cellValue) : null;
1660
- const rb = b ? b(cellValue) : null;
1661
- if (ra === null) return rb ?? true;
1662
- if (rb === null) return ra;
1663
- return join === "OR" ? ra || rb : ra && rb;
1664
- }),
1665
- );
1717
+ const only = compiledMenuFilters.length === 1 ? compiledMenuFilters[0]! : null;
1718
+ if (only && !only.b && only.a) {
1719
+ // One column, one condition - the common case. A plain loop over
1720
+ // `test(read(row))`, without the per-row every() closure and the
1721
+ // destructure: 1M rows spent most of their time in that wrapper.
1722
+ const read = only.readValue;
1723
+ const test = only.a;
1724
+ const input = denseRows(rows);
1725
+ const kept: Array<Row<TData>> = [];
1726
+ for (let i = 0; i < input.length; i++) {
1727
+ const row = input[i]!;
1728
+ if (test(read(row))) kept.push(row);
1729
+ }
1730
+ rows = kept;
1731
+ } else {
1732
+ rows = denseRows(rows).filter((row) =>
1733
+ compiledMenuFilters.every(({ readValue, join, a, b }) => {
1734
+ const cellValue = readValue(row);
1735
+ const ra = a ? a(cellValue) : null;
1736
+ const rb = b ? b(cellValue) : null;
1737
+ if (ra === null) return rb ?? true;
1738
+ if (rb === null) return ra;
1739
+ return join === "OR" ? ra || rb : ra && rb;
1740
+ }),
1741
+ );
1742
+ }
1666
1743
  }
1667
1744
 
1668
1745
  const valueFilterEntries = Object.entries(valueFilters).filter(
@@ -1678,7 +1755,7 @@ export function createSvGridController<
1678
1755
  readValue: makeColumnReader(columnId),
1679
1756
  buckets: facetBucketsByColumn.get(columnId) ?? null,
1680
1757
  }));
1681
- rows = rows.filter((row) =>
1758
+ rows = denseRows(rows).filter((row) =>
1682
1759
  bucketEntries.every(({ allowed, buckets, readValue }) => {
1683
1760
  const raw = readValue(row);
1684
1761
  if (buckets) {
@@ -1718,15 +1795,21 @@ export function createSvGridController<
1718
1795
 
1719
1796
  let rows = rawRows;
1720
1797
  if (globalFilter.trim()) {
1721
- const needle = normalizeForFilter(globalFilter, (props.filterLocale ?? props.localization?.locale));
1722
- rows = rows.filter((row) =>
1723
- row
1724
- .getAllCells()
1725
- .some((cell) =>
1726
- normalizeForFilter(String(cell.getValue() ?? ""), (props.filterLocale ?? props.localization?.locale))
1727
- .includes(needle),
1728
- ),
1729
- );
1798
+ const locale = props.filterLocale ?? props.localization?.locale;
1799
+ const needle = normalizeForFilter(globalFilter, locale);
1800
+ // Read the values, not the cells. `getAllCells()` built a cell object
1801
+ // with two closures per column on EVERY row and kept them on the row:
1802
+ // ten million objects for one keystroke over a million rows, and a
1803
+ // search that froze the page for seconds. `cell.getValue()` and
1804
+ // `getCellValueByColumnId` read the same per-row values array, so the
1805
+ // answer is the same. The columns are the ones the rows were built with.
1806
+ const columnIds = grid.getAllColumns().map((c) => c.id);
1807
+ rows = denseRows(rows).filter((row) => {
1808
+ for (const id of columnIds) {
1809
+ if (normalizeForFilter(String(row.getCellValueByColumnId(id) ?? ""), locale).includes(needle)) return true;
1810
+ }
1811
+ return false;
1812
+ });
1730
1813
  }
1731
1814
 
1732
1815
  rows = applyColumnFilters(rows);
@@ -1992,11 +2075,17 @@ export function createSvGridController<
1992
2075
  /** Whether the bar has anything to show: the model's count when it has one, else the loaded ticks. */
1993
2076
  const selectionBarVisible = $derived(selectionBarOn && selectionBarCount > 0);
1994
2077
 
1995
- // Forward selection changes to the consumer. Skips the very first invocation
1996
- // (the initial empty state) so consumers don't get a spurious callback on mount.
1997
- let lastSelectionSerialized = "";
2078
+ // Forward selection changes to the consumer. The first run records the
2079
+ // starting selection instead of reporting it, so consumers don't get a
2080
+ // spurious callback on mount. (This was seeded with "", which never matched
2081
+ // "{}", so the mount call this comment always promised to skip was made.)
2082
+ let lastSelectionSerialized: string | null = null;
1998
2083
  $effect(() => {
1999
2084
  const serialized = JSON.stringify(rowSelectionState);
2085
+ if (lastSelectionSerialized === null) {
2086
+ lastSelectionSerialized = serialized;
2087
+ return;
2088
+ }
2000
2089
  if (serialized === lastSelectionSerialized) return;
2001
2090
  lastSelectionSerialized = serialized;
2002
2091
  const callback = props.onRowSelectionChange;
@@ -2015,19 +2104,27 @@ export function createSvGridController<
2015
2104
  // Forward cell-selection rectangle changes to the consumer. Same
2016
2105
  // dedupe pattern - fires only when the serialized rectangle changes
2017
2106
  // so consumers don't see spurious callbacks during re-renders.
2018
- let lastCellRangeSerialized = "";
2019
- $effect(() => {
2020
- // Every rectangle, the committed ones a Ctrl+click added and the
2021
- // active one last, the order getSelectionRects gives: a consumer's
2022
- // status bar and shading cover the whole selection, not only its last
2023
- // range. Depend on selectionRanges so a change to the committed set
2024
- // re-runs this too.
2025
- void selectionRanges;
2026
- const ranges: Array<[number, number, number, number]> = getSelectionRects().map(
2107
+ // Every rectangle, the committed ones a Ctrl+click added and the active one
2108
+ // last, the order getSelectionRects gives: a consumer's status bar and
2109
+ // shading cover the whole selection, not only its last range.
2110
+ const currentCellRanges = (): Array<[number, number, number, number]> =>
2111
+ getSelectionRects().map(
2027
2112
  (r: { minRow: number; minCol: number; maxRow: number; maxCol: number }) =>
2028
2113
  [r.minRow, r.minCol, r.maxRow, r.maxCol] as [number, number, number, number],
2029
2114
  );
2115
+ // `null` until the first run, which records the starting selection rather
2116
+ // than reporting it: mounting is not a change. (Not seeded eagerly -
2117
+ // getSelectionRects is bound further down this file.)
2118
+ let lastCellRangeSerialized: string | null = null;
2119
+ $effect(() => {
2120
+ // Depend on selectionRanges so a change to the committed set re-runs this too.
2121
+ void selectionRanges;
2122
+ const ranges = currentCellRanges();
2030
2123
  const serialized = JSON.stringify(ranges);
2124
+ if (lastCellRangeSerialized === null) {
2125
+ lastCellRangeSerialized = serialized;
2126
+ return;
2127
+ }
2031
2128
  if (serialized === lastCellRangeSerialized) return;
2032
2129
  lastCellRangeSerialized = serialized;
2033
2130
  props.onCellSelectionChange?.(ranges);
@@ -2871,27 +2968,40 @@ export function createSvGridController<
2871
2968
 
2872
2969
  // Forward sort-clause changes to the consumer. Same dedupe pattern as the
2873
2970
  // selection callback above - fires only when the serialized clauses change.
2874
- let lastSortingSerialized = "";
2971
+ // Seeded from the starting sort, so mounting is not reported as a change:
2972
+ // it used to be, and a consumer that keeps the sort in the URL (reset the
2973
+ // page on a new sort) navigated away from ?page=2 on every load, while a
2974
+ // server data source refetched page 0 for nothing.
2975
+ const currentSorting = () =>
2976
+ (grid.getState().sorting ?? []) as Array<{ id: string; desc: boolean }>;
2977
+ let lastSortingSerialized = JSON.stringify(untrack(currentSorting));
2875
2978
  $effect(() => {
2876
2979
  gridStateVersion;
2877
- const sorting = (grid.getState().sorting ?? []) as Array<{
2878
- id: string;
2879
- desc: boolean;
2880
- }>;
2980
+ const sorting = currentSorting();
2881
2981
  const serialized = JSON.stringify(sorting);
2882
2982
  if (serialized === lastSortingSerialized) return;
2883
2983
  lastSortingSerialized = serialized;
2884
2984
  props.onSortingChange?.(sorting);
2885
2985
  });
2886
2986
 
2987
+ // A row model starts unsorted and takes no initial sort of its own. It
2988
+ // learned `initialSorting` from the mount report above; now that there is
2989
+ // none, hand the starting sort over directly - to each model the grid is
2990
+ // given, and only when there is a sort to hand over.
2991
+ $effect(() => {
2992
+ const model = rawProps.rowModel;
2993
+ untrack(() => {
2994
+ const sorting = currentSorting();
2995
+ if (model?.setSort && sorting.length) model.setSort(sorting);
2996
+ });
2997
+ });
2998
+
2887
2999
  // Forward filter-state changes to the consumer. Consolidates the three
2888
3000
  // wrapper-managed filter stores (global text, per-column operator filters,
2889
3001
  // facet checklists) into one shape so server-side consumers can build a
2890
3002
  // single query. Skipped entirely when no callback is registered to avoid
2891
3003
  // serializing on every keystroke.
2892
- let lastFiltersSerialized = "";
2893
- $effect(() => {
2894
- if (!props.onFiltersChange) return;
3004
+ function currentFilters() {
2895
3005
  const menuEntries = Object.entries(filterMenuValues)
2896
3006
  .filter(([, f]) => {
2897
3007
  if (f.operator === "isBlank" || f.operator === "isNotBlank") return true;
@@ -2933,10 +3043,16 @@ export function createSvGridController<
2933
3043
  : entry,
2934
3044
  );
2935
3045
  }
2936
- const payload = {
3046
+ return {
2937
3047
  global: globalFilter,
2938
3048
  columns: Array.from(merged.values()),
2939
3049
  };
3050
+ }
3051
+ // Seeded from the starting filters (empty), so mounting is not a change.
3052
+ let lastFiltersSerialized = JSON.stringify(untrack(currentFilters));
3053
+ $effect(() => {
3054
+ if (!props.onFiltersChange) return;
3055
+ const payload = currentFilters();
2940
3056
  const serialized = JSON.stringify(payload);
2941
3057
  if (serialized === lastFiltersSerialized) return;
2942
3058
  lastFiltersSerialized = serialized;
@@ -3118,6 +3234,34 @@ export function createSvGridController<
3118
3234
  const frozenRowList = $derived(frozenRowCount ? allRows.slice(0, frozenRowCount) : []);
3119
3235
  /** The merged cells, indexed; null (the usual case) costs nothing. */
3120
3236
  const mergeIndex = $derived(buildMergeIndex(props.mergedCells));
3237
+ /**
3238
+ * Rows laid out as flex lines rather than table rows (SvGrid.css,
3239
+ * `.sv-grid-table-flex`): a change to one row's cells no longer lays out
3240
+ * every other row. A merged cell spanning rows needs the table algorithm,
3241
+ * so any such merge, or `rowLayout: 'table'`, keeps table layout.
3242
+ */
3243
+ const flexRows = $derived(
3244
+ props.rowLayout !== "table" &&
3245
+ !(props.mergedCells ?? []).some((m: { rowSpan?: number }) => (m.rowSpan ?? 1) > 1),
3246
+ );
3247
+ // The spreadsheetLayout action (a use: directive on a wrapper, outside this
3248
+ // component) switches the table back to table layout on the DOM, with
3249
+ // data-row-layout="table". Watch for it, so the shortcuts that only hold
3250
+ // under flex rows (no right spacer) switch off with it.
3251
+ let tableLayoutForced = $state(false);
3252
+ $effect(() => {
3253
+ const el = gridRootEl;
3254
+ if (!el || typeof MutationObserver === "undefined") return;
3255
+ const read = () => {
3256
+ tableLayoutForced = el.getAttribute("data-row-layout") === "table";
3257
+ };
3258
+ read();
3259
+ const observer = new MutationObserver(read);
3260
+ observer.observe(el, { attributes: true, attributeFilter: ["data-row-layout"] });
3261
+ return () => observer.disconnect();
3262
+ });
3263
+ /** Rows really are flex lines: flexRows, and no spreadsheetLayout forcing table layout. */
3264
+ const flexLayoutActive = $derived(flexRows && !tableLayoutForced);
3121
3265
  const frozenBandHeight = $derived.by(() => {
3122
3266
  if (!frozenRowCount) return 0;
3123
3267
  rowResizeVersion;
@@ -3262,51 +3406,200 @@ export function createSvGridController<
3262
3406
  });
3263
3407
  };
3264
3408
 
3409
+ /**
3410
+ * The column window before any measurement: the columns that fit an
3411
+ * estimated viewport (the measured width once known, else 1,920 px) plus the
3412
+ * overscan, anchored at column 0. Deterministic, so a server render and the
3413
+ * first client render agree. It used to be every column, which put 10,000
3414
+ * columns' cells in the DOM for one frame and cost most of a wide mount.
3415
+ */
3416
+ const preMeasureColumnItems = () => {
3417
+ const viewport = viewportWidth > 0 ? viewportWidth : 1920;
3418
+ let extra = Math.max(props.columnOverscan ?? COLUMN_OVERSCAN, 0);
3419
+ const items: Array<{ index: number; key: number; size: number; start: number; end: number }> = [];
3420
+ let start = 0;
3421
+ for (let i = 0; i < allColumns.length; i += 1) {
3422
+ if (start >= viewport && extra-- <= 0) break;
3423
+ const size = getColumnWidth(allColumns[i]!.id);
3424
+ items.push({ index: i, key: i, size, start, end: start + size });
3425
+ start += size;
3426
+ }
3427
+ return items;
3428
+ };
3429
+
3265
3430
  const renderedColumnItems = $derived.by(function renderedColumnItems_d() {
3266
3431
  if (!columnVirtualizationEnabled) return allColumnItems();
3267
3432
  // Column virtualization is ON by default and its virtualizer, like the row
3268
3433
  // one, only learns `count` from an effect - which never runs on a server.
3269
- // Without this the server emitted rows containing no data cells. Render the
3270
- // full set pre-measurement: it is what a crawler should see, it matches the
3271
- // first client render so hydration is clean, and the real window replaces
3272
- // it as soon as the measuring effect lands.
3273
- if (virtualColumns.length === 0 && allColumns.length > 0) return allColumnItems();
3434
+ // Without this the server emitted rows containing no data cells. Before a
3435
+ // measurement the window is the columns that fit an estimated viewport.
3436
+ if (virtualColumns.length === 0 && allColumns.length > 0 && effectivePinning.left.length === 0 && effectivePinning.right.length === 0) {
3437
+ return preMeasureColumnItems();
3438
+ }
3274
3439
  // Pinned columns are position:sticky, so they only stay pinned while their
3275
- // cell is in the DOM. Plain column virtualization drops them once they leave
3276
- // the scroll window, and the pinned column vanishes. Because allColumns is
3277
- // ordered [pinnedLeft, unpinned, pinnedRight], we keep the rendered window
3278
- // CONTIGUOUS from the pinned-left prefix (index 0) through the pinned-right
3279
- // suffix (last index) whenever those exist. The pinned cells are then always
3280
- // rendered - the existing single-spacer layout positions everything, so no
3281
- // markup changes are needed. (Cost: with a pinned side, the columns between
3282
- // that edge and the window are also rendered; negligible for typical grids,
3283
- // and correctness beats shaving a few off-screen cells.)
3284
- const window = virtualColumns;
3285
- const hasLeft = effectivePinning.left.length > 0;
3286
- const hasRight = effectivePinning.right.length > 0;
3287
- if ((!hasLeft && !hasRight) || window.length === 0) return window;
3288
-
3289
- const firstIdx = window[0]!.index;
3290
- const lastIdx = window[window.length - 1]!.index;
3291
- const startIndex = hasLeft ? 0 : firstIdx;
3292
- const endIndex = hasRight ? allColumns.length - 1 : lastIdx;
3293
- if (startIndex === firstIdx && endIndex === lastIdx) return window;
3294
-
3295
- const items: Array<{ index: number; key: number; size: number; start: number; end: number }> = [];
3440
+ // cell is in the DOM. allColumns is ordered [pinnedLeft, unpinned,
3441
+ // pinnedRight], so the rendered set is three runs: the pinned-left prefix,
3442
+ // the virtualizer's window, and the pinned-right suffix. Each run keeps its
3443
+ // real offsets, and the templates draw a spacer cell for the columns
3444
+ // between two runs (`gap` on `renderedColumns`), so the centre stays
3445
+ // virtualized however many columns sit between the pinned edges.
3446
+ const window = virtualColumns.length > 0 ? virtualColumns : preMeasureColumnItems();
3447
+ const leftCount = effectivePinning.left.length;
3448
+ const rightCount = effectivePinning.right.length;
3449
+ if ((leftCount === 0 && rightCount === 0) || window.length === 0) return window;
3450
+
3451
+ const total = allColumns.length;
3452
+ type PinnedItem = { index: number; key: number; size: number; start: number; end: number };
3453
+ const items: Array<(typeof window)[number] | PinnedItem> = [];
3454
+ // Pinned-left prefix: offsets from 0.
3296
3455
  let offset = 0;
3297
- for (let i = 0; i < startIndex; i += 1) offset += getColumnWidth(allColumns[i]!.id);
3298
- for (let i = startIndex; i <= endIndex; i += 1) {
3456
+ for (let i = 0; i < Math.min(leftCount, total); i += 1) {
3299
3457
  const size = getColumnWidth(allColumns[i]!.id);
3300
3458
  items.push({ index: i, key: i, size, start: offset, end: offset + size });
3301
3459
  offset += size;
3302
3460
  }
3461
+ // The window, minus any pinned column it already covers.
3462
+ const firstRight = total - rightCount;
3463
+ for (const item of window) {
3464
+ if (item.index < leftCount || item.index >= firstRight) continue;
3465
+ items.push(item);
3466
+ }
3467
+ // Pinned-right suffix: offsets measured back from the virtualizer's total,
3468
+ // the same total the right spacer is computed against.
3469
+ const right: PinnedItem[] = [];
3470
+ let end = virtualColumnTotalSize;
3471
+ if (end <= 0) {
3472
+ // Not measured yet: the virtualizer has no total until its effect runs.
3473
+ for (const column of allColumns) end += getColumnWidth(column.id);
3474
+ }
3475
+ for (let i = total - 1; i >= Math.max(firstRight, leftCount); i -= 1) {
3476
+ const size = getColumnWidth(allColumns[i]!.id);
3477
+ right.push({ index: i, key: i, size, start: end - size, end });
3478
+ end -= size;
3479
+ }
3480
+ for (let i = right.length - 1; i >= 0; i -= 1) items.push(right[i]!);
3303
3481
  return items;
3304
3482
  });
3305
- const renderedColumns = $derived.by(() =>
3306
- renderedColumnItems
3307
- .map((item) => ({ item, column: allColumns[item.index] }))
3308
- .filter(hasRenderedColumn),
3309
- );
3483
+ /**
3484
+ * The rendered columns, each with `gap`: the width of the columns NOT
3485
+ * rendered between it and the previous rendered column. Non-zero only at the
3486
+ * seams of the pinned/centre runs above; every row template draws a spacer
3487
+ * cell of that width before the column, so all rows keep the same slots.
3488
+ */
3489
+ // The entries handed out last time, by column id. A horizontal scroll moves
3490
+ // the window by a column or two per frame; an entry whose column, offset,
3491
+ // width and gap are unchanged is handed out again as the SAME object, so
3492
+ // the keyed `{#each}` in every row leaves those cells alone and only the
3493
+ // columns that entered the window are rendered. A fresh object per column
3494
+ // per frame re-ran every visible cell's effects on every scroll frame.
3495
+ let renderedColumnsPrev = new Map<string, { item: { index: number; start: number; size: number; end: number }; column: unknown; gap: number; key: string; pinned: "left" | "right" | null }>();
3496
+ // The body cells' {#each} key. A virtualized window column is keyed by a
3497
+ // slot, not by its id: a column keeps its slot (and so its <td>) while it
3498
+ // stays in the window, and a column entering the window takes a slot a
3499
+ // leaving column freed. A one-column scroll then hands one cell per row to
3500
+ // the new column, and a thumb drag that jumps to columns far away updates
3501
+ // every cell in place instead of destroying the window's cells and creating
3502
+ // new ones (~800 at a time on a wide grid). There are as many slots as the
3503
+ // widest window so far. Pinned columns, and every column without column
3504
+ // virtualization, keep their id.
3505
+ let columnSlots = new Map<string, number>();
3506
+ let columnSlotOrder: number[] = [];
3507
+ let columnSlotCount = 0;
3508
+ const renderedColumns = $derived.by(() => {
3509
+ const out: Array<{ item: (typeof renderedColumnItems)[number]; column: NonNullable<(typeof allColumns)[number]>; gap: number; key: string; pinned: "left" | "right" | null }> = [];
3510
+ const next = new Map<string, (typeof out)[number]>();
3511
+ const total = allColumns.length;
3512
+ const leftCount = effectivePinning.left.length;
3513
+ const firstRight = total - effectivePinning.right.length;
3514
+ const inWindow = (index: number) => columnVirtualizationEnabled && index >= leftCount && index < firstRight;
3515
+ // Window columns already holding a slot keep it; the others take the
3516
+ // slots nobody kept, in the order those cells sat in the row, then new
3517
+ // ones. In that order a jump to an unrelated window keeps the cells where
3518
+ // they are, and a step moves only the cells that changed column: Svelte
3519
+ // reorders keyed cells by key order.
3520
+ const slots = new Map<string, number>();
3521
+ const taken = new Set<number>();
3522
+ for (const item of renderedColumnItems) {
3523
+ const id = allColumns[item.index]?.id;
3524
+ if (id === undefined || !inWindow(item.index)) continue;
3525
+ const slot = columnSlots.get(id);
3526
+ if (slot !== undefined) {
3527
+ slots.set(id, slot);
3528
+ taken.add(slot);
3529
+ }
3530
+ }
3531
+ const free = columnSlotOrder.filter((slot) => !taken.has(slot));
3532
+ let nextFree = 0;
3533
+ const order: number[] = [];
3534
+ for (const item of renderedColumnItems) {
3535
+ const id = allColumns[item.index]?.id;
3536
+ if (id === undefined || !inWindow(item.index)) continue;
3537
+ let slot = slots.get(id);
3538
+ if (slot === undefined) {
3539
+ slot = nextFree < free.length ? free[nextFree++]! : columnSlotCount++;
3540
+ slots.set(id, slot);
3541
+ }
3542
+ order.push(slot);
3543
+ }
3544
+ // Slots nobody took this time stay at the end, for the next window.
3545
+ for (let i = nextFree; i < free.length; i += 1) order.push(free[i]!);
3546
+ columnSlots = slots;
3547
+ columnSlotOrder = order;
3548
+ // What isColumnPinned answers, once per column here instead of once per
3549
+ // cell in the template.
3550
+ const pinnedLeft = new Set(effectivePinning.left);
3551
+ const pinnedRight = new Set(effectivePinning.right);
3552
+ let prevEnd: number | null = null;
3553
+ for (const item of renderedColumnItems) {
3554
+ const column = allColumns[item.index];
3555
+ if (column === undefined) continue;
3556
+ let gap = 0;
3557
+ if (prevEnd !== null && item.start - prevEnd >= 0.5) gap = item.start - prevEnd;
3558
+ prevEnd = item.end;
3559
+ const slot = slots.get(column.id);
3560
+ const key = slot !== undefined ? `s:${slot}` : `c:${column.id}`;
3561
+ const pinned: "left" | "right" | null = pinnedLeft.has(column.id) ? "left" : pinnedRight.has(column.id) ? "right" : null;
3562
+ const prev = renderedColumnsPrev.get(column.id) as (typeof out)[number] | undefined;
3563
+ const entry =
3564
+ prev &&
3565
+ prev.column === column &&
3566
+ prev.gap === gap &&
3567
+ prev.key === key &&
3568
+ prev.pinned === pinned &&
3569
+ prev.item.index === item.index &&
3570
+ prev.item.start === item.start &&
3571
+ prev.item.size === item.size
3572
+ ? prev
3573
+ : { item, column, gap, key, pinned };
3574
+ next.set(column.id, entry);
3575
+ out.push(entry);
3576
+ }
3577
+ renderedColumnsPrev = next;
3578
+ return out;
3579
+ });
3580
+ /**
3581
+ * The contiguous runs of rendered column indices, for whoever must not span
3582
+ * across a seam: a merged cell or a column-group header is clipped to the
3583
+ * run it starts in. One run unless pinned columns split the window.
3584
+ */
3585
+ const renderedColumnRuns = $derived.by(function renderedColumnRuns_d() {
3586
+ const runs: Array<{ first: number; last: number; gap: number }> = [];
3587
+ for (const rendered of renderedColumns) {
3588
+ const i = rendered.item.index;
3589
+ const run = runs[runs.length - 1];
3590
+ if (run && i === run.last + 1 && rendered.gap === 0) run.last = i;
3591
+ else runs.push({ first: i, last: i, gap: runs.length === 0 ? 0 : rendered.gap });
3592
+ }
3593
+ return runs;
3594
+ });
3595
+ /** The run holding column `colIndex`, or the whole window if none does. */
3596
+ function columnRunOf(colIndex: number): { firstCol: number; lastCol: number } {
3597
+ const runs = renderedColumnRuns;
3598
+ for (const run of runs) {
3599
+ if (colIndex >= run.first && colIndex <= run.last) return { firstCol: run.first, lastCol: run.last };
3600
+ }
3601
+ return { firstCol: runs[0]?.first ?? 0, lastCol: runs[runs.length - 1]?.last ?? -1 };
3602
+ }
3310
3603
  const totalColumnWidth = $derived.by(function totalColumnWidth_d() {
3311
3604
  if (columnVirtualizationEnabled) return virtualColumnTotalSize;
3312
3605
  let total = 0;
@@ -3355,22 +3648,34 @@ export function createSvGridController<
3355
3648
  const groupHeaderRowsWindowed = $derived.by(function groupHeaderRowsWindowed_d() {
3356
3649
  const base = groupHeaderRows;
3357
3650
  if (!columnVirtualizationEnabled || base.length === 0) return base;
3358
- const items = renderedColumnItems;
3359
- if (items.length === 0) return base;
3360
- const winFirst = items[0]!.index;
3361
- const winLast = items[items.length - 1]!.index; // inclusive
3651
+ const runs = renderedColumnRuns;
3652
+ if (runs.length === 0) return base;
3653
+ // Clip each cell to every run it overlaps. With pinned columns the window
3654
+ // is several runs, so a group spanning a seam becomes one cell per run,
3655
+ // and the first cell of each later run carries the seam's gap.
3362
3656
  return base.map((row) => {
3363
3657
  const cells: GroupHeaderCell[] = [];
3364
- for (const cell of row.cells) {
3365
- const start = cell.firstLeafIndex;
3366
- const end = start + cell.colSpan; // exclusive
3367
- const from = Math.max(start, winFirst);
3368
- const to = Math.min(end, winLast + 1);
3369
- if (to <= from) continue; // cell is entirely outside the window
3370
- let widthPx = 0;
3371
- for (let i = from; i < to; i += 1) widthPx += getColumnWidth(allColumns[i]!.id);
3372
- cells.push({ ...cell, colSpan: to - from, widthPx, firstLeafIndex: from });
3373
- }
3658
+ runs.forEach((run, runIndex) => {
3659
+ let first = true;
3660
+ for (const cell of row.cells) {
3661
+ const start = cell.firstLeafIndex;
3662
+ const end = start + cell.colSpan; // exclusive
3663
+ const from = Math.max(start, run.first);
3664
+ const to = Math.min(end, run.last + 1);
3665
+ if (to <= from) continue; // cell is entirely outside this run
3666
+ let widthPx = 0;
3667
+ for (let i = from; i < to; i += 1) widthPx += getColumnWidth(allColumns[i]!.id);
3668
+ cells.push({
3669
+ ...cell,
3670
+ key: runIndex === 0 ? cell.key : `${cell.key}@${runIndex}`,
3671
+ colSpan: to - from,
3672
+ widthPx,
3673
+ firstLeafIndex: from,
3674
+ gapBefore: first && runIndex > 0 ? run.gap : 0,
3675
+ });
3676
+ first = false;
3677
+ }
3678
+ });
3374
3679
  return { ...row, cells };
3375
3680
  });
3376
3681
  });
@@ -3756,10 +4061,14 @@ export function createSvGridController<
3756
4061
  // instead of `scrollVersion` so this effect does NOT re-run on every
3757
4062
  // scroll event - which would otherwise re-call setOptions hundreds of
3758
4063
  // times during a drag.
3759
- viewportVersion;
3760
- const viewportHeight =
4064
+ // The measured height comes from the `viewportHeight` derived, which
4065
+ // reads clientHeight only when viewportVersion moves. Reading it here
4066
+ // directly did so on every run of this effect - each filter, sort or
4067
+ // data change - in the middle of the DOM update, forcing a layout the
4068
+ // frame then repeated (6 ms of a 100k-row filter).
4069
+ const measuredHeight =
3761
4070
  typeof props.containerHeight === "string"
3762
- ? (scrollContainer?.clientHeight ?? 520)
4071
+ ? (scrollContainer ? viewportHeight : 520)
3763
4072
  : (props.containerHeight ?? 520);
3764
4073
  const rh = props.rowHeight;
3765
4074
  // Auto height: read measured sizes, falling back to the fixed rowHeight as
@@ -3788,7 +4097,7 @@ export function createSvGridController<
3788
4097
  count: allRows.length,
3789
4098
  estimateSize,
3790
4099
  overscan: props.overscan ?? 8,
3791
- viewportHeight,
4100
+ viewportHeight: measuredHeight,
3792
4101
  });
3793
4102
  });
3794
4103
 
@@ -3838,28 +4147,76 @@ export function createSvGridController<
3838
4147
 
3839
4148
  $effect(() => {
3840
4149
  // Reading columnWidths here registers it as a reactive dependency so
3841
- // the effect re-runs when the user resizes a column. We pass a fresh
3842
- // closure each run; the virtualizer sees a new function reference and
3843
- // re-derives its layout from the current per-column widths.
3844
- columnWidths;
3845
- collapsedColumns;
4150
+ // the effect re-runs when the user resizes a column. The widths are read
4151
+ // once into an array and the size function reads that: passing a size
4152
+ // function makes the virtualizer rebuild every column's offset, and
4153
+ // resolving each width through getColumnWidth inside that rebuild cost
4154
+ // several reactive reads per column - 10,000 times on a wide grid.
4155
+ // getColumnWidth's rules, with the maps read once: collapsed is 0, a
4156
+ // fitted width wins unless the user resized the column, then the user's
4157
+ // width, and otherwise getColumnBaseWidth (the definition or the default,
4158
+ // exactly as the cells are sized).
4159
+ const resized = columnWidths;
4160
+ const collapsed = collapsedColumns;
4161
+ const fitted = fittedColumnWidths;
4162
+ const widths = new Float64Array(allColumns.length);
4163
+ for (let i = 0; i < widths.length; i++) {
4164
+ const id = allColumns[i]!.id;
4165
+ const own = resized[id];
4166
+ widths[i] = collapsed[id]
4167
+ ? 0
4168
+ : own !== undefined
4169
+ ? own
4170
+ : (fitted?.[id] ?? getColumnBaseWidth(id));
4171
+ }
4172
+ // `columnOverscan` columns stay rendered ahead of a scroll at all times.
4173
+ // The window does not move on every column boundary: it stays put while
4174
+ // that many columns are left ahead, then moves COLUMN_WINDOW_STEP
4175
+ // columns at once (overscanMin). A move costs about the same whatever
4176
+ // the number of columns entering, in every rendered row, so moving four
4177
+ // at a time did the same scroll for about half the work (1,000 columns,
4178
+ // 120 px per frame, interleaved A/B), and the frames in between change
4179
+ // nothing. Three, not four: a move is one longer frame, and three gave
4180
+ // the shortest longest frame of a wheel scroll (production build: 8.0 ms
4181
+ // against 10.2 at four and 9.4 at two) for about the same total work.
4182
+ const minAhead = Math.max(props.columnOverscan ?? COLUMN_OVERSCAN, 0);
3846
4183
  columnVirtualizer.setOptions({
3847
- count: allColumns.length,
3848
- estimateSize: (index: number) => {
3849
- const column = allColumns[index];
3850
- return column ? getColumnWidth(column.id) : (props.columnWidth ?? 140);
3851
- },
3852
- overscan: props.columnOverscan ?? 3,
3853
- viewportHeight: viewportWidth,
4184
+ count: widths.length,
4185
+ estimateSize: (index: number) => widths[index] ?? (props.columnWidth ?? 140),
4186
+ overscan: minAhead + COLUMN_WINDOW_STEP - 1,
4187
+ // One column behind the scroll: the columns the scroll is leaving were
4188
+ // just passed, and a reversal needs one at most for the frame before
4189
+ // the window turns around.
4190
+ overscanBehind: Math.min(1, minAhead),
4191
+ overscanMin: minAhead,
3854
4192
  });
3855
4193
  });
4194
+ // The viewport on its own: a width change re-windows the columns without
4195
+ // rebuilding their offsets.
4196
+ $effect(() => {
4197
+ columnVirtualizer.setViewportWidth(viewportWidth);
4198
+ });
3856
4199
 
4200
+ // Re-sync the virtualizers to the scroll position. This effect re-runs on
4201
+ // anything domToLogicalRowOffset reads (the row count among them), and
4202
+ // reading scrollTop / scrollLeft in the middle of that update forced a
4203
+ // layout the frame then did again (5 ms of a 100k-row filter). The live
4204
+ // position is read once per container; after that the last position the
4205
+ // scroll handler or a programmatic scroll recorded (pendingScrollTop /
4206
+ // pendingScrollLeft) is the same number without the layout.
4207
+ let scrollSyncedContainer: HTMLDivElement | null = null;
3857
4208
  $effect(() => {
3858
- if (!scrollContainer) return;
4209
+ const el = scrollContainer;
4210
+ if (!el) return;
4211
+ if (el !== scrollSyncedContainer) {
4212
+ scrollSyncedContainer = el;
4213
+ pendingScrollTop = el.scrollTop;
4214
+ pendingScrollLeft = el.scrollLeft;
4215
+ }
3859
4216
  if (rowVirtualizationEnabled)
3860
- virtualizer.setScrollOffset(domToLogicalRowOffset(scrollContainer.scrollTop));
4217
+ virtualizer.setScrollOffset(domToLogicalRowOffset(pendingScrollTop));
3861
4218
  if (columnVirtualizationEnabled)
3862
- columnVirtualizer.setHorizontalOffset(scrollContainer.scrollLeft);
4219
+ columnVirtualizer.setHorizontalOffset(pendingScrollLeft);
3863
4220
  });
3864
4221
 
3865
4222
  // Re-arms once the user scrolls away from the bottom, so a long lazy-load
@@ -4509,6 +4866,8 @@ export function createSvGridController<
4509
4866
  get columnWidths() { return columnWidths; },
4510
4867
  get collapsedColumns() { return collapsedColumns; },
4511
4868
  get mergeIndex() { return mergeIndex; },
4869
+ get flexRows() { return flexRows; },
4870
+ get flexLayoutActive() { return flexLayoutActive; },
4512
4871
  get setColumnCollapsed() { return setColumnCollapsed; },
4513
4872
  set columnWidths(v) { columnWidths = v as never; },
4514
4873
  get MIN_COLUMN_WIDTH() { return MIN_COLUMN_WIDTH; },
@@ -4978,6 +5337,8 @@ export function createSvGridController<
4978
5337
  get renderedColumnItems() { return renderedColumnItems; },
4979
5338
  get hasRenderedColumn() { return hasRenderedColumn; },
4980
5339
  get renderedColumns() { return renderedColumns; },
5340
+ get renderedColumnRuns() { return renderedColumnRuns; },
5341
+ get columnRunOf() { return columnRunOf; },
4981
5342
  get totalColumnWidth() { return totalColumnWidth; },
4982
5343
  get hasHorizontalOverflow() { return hasHorizontalOverflow; },
4983
5344
  get columnWindowStart() { return columnWindowStart; },