@svgrid/grid 3.0.8 → 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 (80) hide show
  1. package/dist/GridMenus.svelte +3 -2
  2. package/dist/SvGrid.controller.svelte.d.ts +60 -8
  3. package/dist/SvGrid.controller.svelte.js +475 -110
  4. package/dist/SvGrid.css +186 -3
  5. package/dist/SvGrid.svelte +281 -163
  6. package/dist/SvGrid.types.d.ts +24 -7
  7. package/dist/cdn/{GridMenus-D15bhpX5.js → GridMenus-B1sipj6y.js} +165 -165
  8. package/dist/cdn/{GridMenus-B8-Gbfq2.js → GridMenus-DlAzfHLk.js} +201 -201
  9. package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
  10. package/dist/cdn/{src-Dz625Fhx.js → src-BYsa66I0.js} +9738 -10174
  11. package/dist/cdn/{src-CleI0g9l.js → src-pA4RroLV.js} +10702 -11138
  12. package/dist/cdn/svgrid.js +27 -27
  13. package/dist/cdn/svgrid.svelte-external.js +27 -27
  14. package/dist/cdn/{validate-GEs4ZPSW.js → validate-XuMe4_KR.js} +2 -2
  15. package/dist/cell-render.js +25 -3
  16. package/dist/columns.js +16 -1
  17. package/dist/conditional-formatting.js +9 -2
  18. package/dist/core.d.ts +93 -0
  19. package/dist/core.js +313 -52
  20. package/dist/filtering/excel-filters.js +41 -6
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/selection.d.ts +2 -2
  24. package/dist/selection.js +32 -8
  25. package/dist/server-block-cache.d.ts +7 -32
  26. package/dist/server-block-cache.js +21 -12
  27. package/dist/server-data-source.js +3 -1
  28. package/dist/server.d.ts +1 -0
  29. package/dist/server.js +1 -0
  30. package/dist/spreadsheet.js +7 -0
  31. package/dist/validate.js +3 -11
  32. package/dist/virtualization/column-virtualizer.d.ts +4 -0
  33. package/dist/virtualization/column-virtualizer.js +2 -0
  34. package/dist/virtualization/types.d.ts +20 -0
  35. package/dist/virtualization/virtualizer.js +90 -24
  36. package/dist/windowed-brand.d.ts +22 -0
  37. package/dist/windowed-brand.js +60 -0
  38. package/dist/windowed-data.d.ts +6 -0
  39. package/dist/windowed-data.js +34 -0
  40. package/dist/windowed-row-model.d.ts +1 -0
  41. package/dist/windowed-row-model.js +97 -0
  42. package/package.json +1 -1
  43. package/src/GridMenus.svelte +3 -2
  44. package/src/SvGrid.controller.svelte.ts +469 -114
  45. package/src/SvGrid.css +186 -3
  46. package/src/SvGrid.svelte +281 -163
  47. package/src/SvGrid.types.ts +24 -7
  48. package/src/cell-render.ts +24 -2
  49. package/src/columns.ts +16 -1
  50. package/src/conditional-formatting.ts +6 -4
  51. package/src/core.sort.test.ts +61 -0
  52. package/src/core.ts +309 -52
  53. package/src/counting-cell.test.svelte +11 -0
  54. package/src/filtering/excel-filters.ts +39 -6
  55. package/src/headless.reactivity.svelte.test.ts +1 -1
  56. package/src/index.ts +6 -0
  57. package/src/new-features.test.ts +24 -0
  58. package/src/selection.test.ts +14 -0
  59. package/src/selection.ts +31 -8
  60. package/src/server-block-cache.ts +27 -10
  61. package/src/server-data-source.ts +3 -1
  62. package/src/server.ts +6 -0
  63. package/src/spreadsheet.ts +6 -0
  64. package/src/svgrid.behavior.test.ts +29 -0
  65. package/src/svgrid.column-cell-recycling.test.ts +120 -0
  66. package/src/svgrid.interaction.test.ts +4 -1
  67. package/src/svgrid.new-features.wrapper.test.ts +5 -4
  68. package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
  69. package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
  70. package/src/validate.test.ts +4 -4
  71. package/src/validate.ts +3 -14
  72. package/src/virtualization/column-virtualizer.ts +6 -0
  73. package/src/virtualization/types.ts +20 -0
  74. package/src/virtualization/virtualizer.test.ts +152 -0
  75. package/src/virtualization/virtualizer.ts +93 -27
  76. package/src/windowed-brand.ts +67 -0
  77. package/src/windowed-data.test.ts +105 -0
  78. package/src/windowed-data.ts +36 -0
  79. package/src/windowed-row-model.ts +113 -0
  80. package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
@@ -1,6 +1,9 @@
1
1
  import { applyGroupAggregate, applyRowPredicate, compileExcelFilter, getAdvancedFilterEngine, normalizeForFilter, createColumnVirtualizer, createCoreRowModel, createExpandedRowModel, createFilteredRowModel, createGroupedRowModel, createTreeRowModel, createSvelteVirtualizer, createSortedRowModel, createSvGrid, getGridCellDomId, sortFns, } from "./index.js";
2
2
  import { createRowScrollScaling, resolveMaxDomHeight, } from "./virtualization/scroll-scaling.js";
3
3
  import { buildPreMeasureItems } from "./virtualization/virtualizer.js";
4
+ // Not on the public barrel: turns a lazy row list into a plain array before a full pass.
5
+ import { denseRows } from "./core.js";
6
+ import { isWindowedData } from "./windowed-brand.js";
4
7
  import { DEV } from "esm-env";
5
8
  import "./sv-grid-scrollbar.js";
6
9
  import { computeColumnStat, formatsNeedingStats, formatNeedsStats, } from "./conditional-formatting.js";
@@ -38,6 +41,13 @@ import { untrack } from "svelte";
38
41
  * ~17.9M, Chrome/Safari ~33.5M) so it is always safe, if coarser than needed.
39
42
  */
40
43
  const MAX_DOM_SCROLL_HEIGHT_FALLBACK = 8000000;
44
+ /** Columns the column window moves by during a horizontal scroll. */
45
+ const COLUMN_WINDOW_STEP = 3;
46
+ /** The default `columnOverscan`. A margin of 2 measured 6% less scroll work,
47
+ * but the window at rest is `1 + columnOverscan` columns wide wherever the
48
+ * viewport measures 0 px - jsdom, where apps test their grids - and 2 left
49
+ * the fourth column out of tests that read it. */
50
+ const COLUMN_OVERSCAN = 3;
41
51
  /**
42
52
  * The browser's actual maximum *scrollable* element height in CSS px. Browsers
43
53
  * clamp how tall a single element may be, and the cap is lower on mobile /
@@ -163,6 +173,12 @@ export function createSvGridController(rawProps, domIdBase) {
163
173
  * Falls back to the historical literal for direct controller construction.
164
174
  */
165
175
  const gridDomId = domIdBase ?? "svgrid";
176
+ // Detect the browser's element-height cap now, while this grid has no DOM.
177
+ // The probe reads offsetHeight, which lays out the whole document; run
178
+ // mid-render it laid out this grid's first frame too, and on a wide grid
179
+ // that one layout was seconds. Cached, so later calls are free.
180
+ if (typeof document !== "undefined")
181
+ getMaxDomScrollHeight();
166
182
  // Runtime option overrides set via the imperative api (`api.setOption(key, value)`).
167
183
  // Every controller read of a prop goes through the `props` proxy below (and the view
168
184
  // reads controller-resolved gates like `_features` / `editingEnabled`, not the raw
@@ -381,7 +397,11 @@ export function createSvGridController(rawProps, domIdBase) {
381
397
  /** When an edit starts: true selects all text, false places the caret at the end. */
382
398
  let editorSelectAll = true;
383
399
  /** Per-column width overrides set by the resize handles. */
384
- let columnWidths = $state({});
400
+ // Raw, like collapsedColumns and hiddenColumns below: every writer replaces
401
+ // the object, and a deep proxy allocated a reactive source for each column
402
+ // id anything asked about - every column, on every width rebuild, on a
403
+ // 10,000-column grid.
404
+ let columnWidths = $state.raw({});
385
405
  /**
386
406
  * Columns folded to nothing, the way a sheet hides a column: the column
387
407
  * keeps its index, its cells and its width for later, and takes no room.
@@ -389,7 +409,7 @@ export function createSvGridController(rawProps, domIdBase) {
389
409
  * and shifts every index after it; a spreadsheet's formulas and formats
390
410
  * are keyed by index and could not follow that.
391
411
  */
392
- let collapsedColumns = $state({});
412
+ let collapsedColumns = $state.raw({});
393
413
  function setColumnCollapsed(columnId, collapsed) {
394
414
  if (!!collapsedColumns[columnId] === collapsed)
395
415
  return;
@@ -478,7 +498,10 @@ export function createSvGridController(rawProps, domIdBase) {
478
498
  let contextMenuPos = $state({ x: 0, y: 0 });
479
499
  // Editable comments: internal overlay (rowId -> columnId -> note) merged on
480
500
  // top of props.notes for immediate feedback, plus the open-editor state.
481
- let noteOverrides = $state({});
501
+ // Raw: writeNote replaces the object (and the row map) on every edit, and a
502
+ // deep proxy made every cell that asked about a note it does not have
503
+ // allocate a reactive source for that row id (0.3 ms of a far jump).
504
+ let noteOverrides = $state.raw({});
482
505
  let commentEditFor = $state(null);
483
506
  let commentDraft = $state("");
484
507
  let valueFilters = $state({});
@@ -535,11 +558,13 @@ export function createSvGridController(rawProps, domIdBase) {
535
558
  scrollHeight: 0,
536
559
  scrollWidth: 0,
537
560
  });
538
- $effect(function scrollMetrics_e() {
539
- scrollVersion;
540
- viewportVersion;
541
- virtualizer.version;
542
- columnVirtualizerVersion;
561
+ // The four reads below need a clean layout. This effect runs right after a
562
+ // scroll flush has written the new rows and columns, so reading here forced
563
+ // a synchronous layout on every scroll frame (~8 ms a frame on a 10,000-
564
+ // column grid). Instead the read waits for the start of the next frame,
565
+ // when the browser has already laid out and painted; triggers in between
566
+ // collapse into that one read.
567
+ function readScrollMetrics() {
543
568
  const el = scrollContainer;
544
569
  if (!el)
545
570
  return;
@@ -547,7 +572,7 @@ export function createSvGridController(rawProps, domIdBase) {
547
572
  const scrollLeft = el.scrollLeft;
548
573
  const scrollHeight = el.scrollHeight;
549
574
  const scrollWidth = el.scrollWidth;
550
- const prev = untrack(() => scrollMetrics);
575
+ const prev = scrollMetrics;
551
576
  if (prev.scrollTop === scrollTop &&
552
577
  prev.scrollLeft === scrollLeft &&
553
578
  prev.scrollHeight === scrollHeight &&
@@ -555,6 +580,20 @@ export function createSvGridController(rawProps, domIdBase) {
555
580
  return;
556
581
  }
557
582
  scrollMetrics = { scrollTop, scrollLeft, scrollHeight, scrollWidth };
583
+ }
584
+ $effect(function scrollMetrics_e() {
585
+ scrollVersion;
586
+ viewportVersion;
587
+ virtualizer.version;
588
+ columnVirtualizerVersion;
589
+ if (!scrollContainer)
590
+ return;
591
+ if (typeof requestAnimationFrame !== "function") {
592
+ untrack(readScrollMetrics);
593
+ return;
594
+ }
595
+ const raf = requestAnimationFrame(readScrollMetrics);
596
+ return () => cancelAnimationFrame(raf);
558
597
  });
559
598
  /** Vertical overflow from the virtualizer's authoritative total size,
560
599
  * NOT from `scrollMetrics.scrollHeight` alone. Reading DOM dimensions
@@ -643,7 +682,7 @@ export function createSvGridController(rawProps, domIdBase) {
643
682
  // svelte-ignore state_referenced_locally
644
683
  let internalColumns = $state.raw(resolveCols(props.columns));
645
684
  // svelte-ignore state_referenced_locally
646
- let hiddenColumns = $state(initialHiddenColumns(props.columns));
685
+ let hiddenColumns = $state.raw(initialHiddenColumns(props.columns));
647
686
  // The columns declared `visible: false` in the ColumnDefs. Kept separately so
648
687
  // applying a saved view (setState) can re-seed from these and never un-hide a
649
688
  // column the app declared hidden (#54).
@@ -690,7 +729,9 @@ export function createSvGridController(rawProps, domIdBase) {
690
729
  // A fresh array object, because `getRowModel` memoises on identity. The
691
730
  // elements are the same objects, so a row that did not move keeps its row
692
731
  // object and a field write still reaches its cell through the proxy.
693
- internalData = length === 0 ? EMPTY_DATA : next.slice();
732
+ // Windowed data (a server row model's rows) is already a fresh array per
733
+ // change and copying it would read every one of its `length` entries.
734
+ internalData = length === 0 ? EMPTY_DATA : isWindowedData(next) ? next : next.slice();
694
735
  // When the consumer replaces `data` (e.g. a "Reset" button), drop any
695
736
  // accumulated cell-edit overrides - otherwise `getCellDisplayValue`
696
737
  // would keep returning the old edited values from `editedCellValues`
@@ -975,14 +1016,28 @@ export function createSvGridController(rawProps, domIdBase) {
975
1016
  };
976
1017
  }
977
1018
  const allColumns = $derived.by(function allColumns_d() {
1019
+ // Read the hidden set and the responsive flag once, not once per column.
1020
+ // `hiddenColumns` is a deep proxy: asking it about an id it does not
1021
+ // hold allocated a reactive source for that id, 10,000 of them on a
1022
+ // 10,000-column mount, and `props.responsive` is a reactive read too.
1023
+ // Iterating the proxy's own keys tracks additions with one dependency.
1024
+ const hiddenIds = new Set();
1025
+ for (const id in hiddenColumns)
1026
+ if (hiddenColumns[id])
1027
+ hiddenIds.add(id);
1028
+ const responsiveOn = !!props.responsive;
1029
+ // Derived values read once too: a read inside the filter is a tracked
1030
+ // get per column.
1031
+ const groupHidden = hiddenByGroupCollapse;
1032
+ const foldedSources = autoGroupSpec.hiddenSourceIds;
978
1033
  let raw = grid
979
1034
  .getAllColumns()
980
- .filter((column) => !hiddenColumns[column.id] &&
981
- !hiddenByGroupCollapse[column.id] &&
982
- !isHiddenByResponsive(column) &&
1035
+ .filter((column) => !hiddenIds.has(column.id) &&
1036
+ !groupHidden[column.id] &&
1037
+ !(responsiveOn && isHiddenByResponsive(column)) &&
983
1038
  // In a column display mode the grouped columns are folded into the
984
1039
  // auto column(s), so showing them too would just duplicate the value.
985
- !autoGroupSpec.hiddenSourceIds.has(column.id));
1040
+ !foldedSources.has(column.id));
986
1041
  // Auto-group columns lead, like a row header.
987
1042
  if (autoGroupColumns.length)
988
1043
  raw = [...autoGroupColumns, ...raw];
@@ -1204,6 +1259,11 @@ export function createSvGridController(rawProps, domIdBase) {
1204
1259
  const formats = props.conditionalFormats;
1205
1260
  if (!formats?.length || !formatsNeedingStats(formats))
1206
1261
  return map;
1262
+ // Windowed data (a server row model) is mostly rows nobody has loaded;
1263
+ // its range is the server's to know. Scanning it would read every row of
1264
+ // the source. Give such formats `minValue` / `maxValue` instead.
1265
+ if (isWindowedData(internalData))
1266
+ return map;
1207
1267
  // `filtered` (default): every row that survives the filters, ignoring the
1208
1268
  // page slice - so a value keeps the same colour as you page through (#61).
1209
1269
  // `visible`: only the rows on screen, rescaling per page. `all`: the full
@@ -1326,16 +1386,34 @@ export function createSvGridController(rawProps, domIdBase) {
1326
1386
  ? compileCond(columnId, f.operator2, f.value2 ?? "", f.valueTo2)
1327
1387
  : null,
1328
1388
  }));
1329
- rows = rows.filter((row) => compiledMenuFilters.every(({ readValue, join, a, b }) => {
1330
- const cellValue = readValue(row);
1331
- const ra = a ? a(cellValue) : null;
1332
- const rb = b ? b(cellValue) : null;
1333
- if (ra === null)
1334
- return rb ?? true;
1335
- if (rb === null)
1336
- return ra;
1337
- return join === "OR" ? ra || rb : ra && rb;
1338
- }));
1389
+ const only = compiledMenuFilters.length === 1 ? compiledMenuFilters[0] : null;
1390
+ if (only && !only.b && only.a) {
1391
+ // One column, one condition - the common case. A plain loop over
1392
+ // `test(read(row))`, without the per-row every() closure and the
1393
+ // destructure: 1M rows spent most of their time in that wrapper.
1394
+ const read = only.readValue;
1395
+ const test = only.a;
1396
+ const input = denseRows(rows);
1397
+ const kept = [];
1398
+ for (let i = 0; i < input.length; i++) {
1399
+ const row = input[i];
1400
+ if (test(read(row)))
1401
+ kept.push(row);
1402
+ }
1403
+ rows = kept;
1404
+ }
1405
+ else {
1406
+ rows = denseRows(rows).filter((row) => compiledMenuFilters.every(({ readValue, join, a, b }) => {
1407
+ const cellValue = readValue(row);
1408
+ const ra = a ? a(cellValue) : null;
1409
+ const rb = b ? b(cellValue) : null;
1410
+ if (ra === null)
1411
+ return rb ?? true;
1412
+ if (rb === null)
1413
+ return ra;
1414
+ return join === "OR" ? ra || rb : ra && rb;
1415
+ }));
1416
+ }
1339
1417
  }
1340
1418
  const valueFilterEntries = Object.entries(valueFilters).filter(([id]) => id !== excludeColumnId);
1341
1419
  if (valueFilterEntries.length) {
@@ -1348,7 +1426,7 @@ export function createSvGridController(rawProps, domIdBase) {
1348
1426
  readValue: makeColumnReader(columnId),
1349
1427
  buckets: facetBucketsByColumn.get(columnId) ?? null,
1350
1428
  }));
1351
- rows = rows.filter((row) => bucketEntries.every(({ allowed, buckets, readValue }) => {
1429
+ rows = denseRows(rows).filter((row) => bucketEntries.every(({ allowed, buckets, readValue }) => {
1352
1430
  const raw = readValue(row);
1353
1431
  if (buckets) {
1354
1432
  // Range-bucketed filter: find which bucket this row's value
@@ -1395,7 +1473,7 @@ export function createSvGridController(rawProps, domIdBase) {
1395
1473
  // `getCellValueByColumnId` read the same per-row values array, so the
1396
1474
  // answer is the same. The columns are the ones the rows were built with.
1397
1475
  const columnIds = grid.getAllColumns().map((c) => c.id);
1398
- rows = rows.filter((row) => {
1476
+ rows = denseRows(rows).filter((row) => {
1399
1477
  for (const id of columnIds) {
1400
1478
  if (normalizeForFilter(String(row.getCellValueByColumnId(id) ?? ""), locale).includes(needle))
1401
1479
  return true;
@@ -1658,11 +1736,17 @@ export function createSvGridController(rawProps, domIdBase) {
1658
1736
  });
1659
1737
  /** Whether the bar has anything to show: the model's count when it has one, else the loaded ticks. */
1660
1738
  const selectionBarVisible = $derived(selectionBarOn && selectionBarCount > 0);
1661
- // Forward selection changes to the consumer. Skips the very first invocation
1662
- // (the initial empty state) so consumers don't get a spurious callback on mount.
1663
- let lastSelectionSerialized = "";
1739
+ // Forward selection changes to the consumer. The first run records the
1740
+ // starting selection instead of reporting it, so consumers don't get a
1741
+ // spurious callback on mount. (This was seeded with "", which never matched
1742
+ // "{}", so the mount call this comment always promised to skip was made.)
1743
+ let lastSelectionSerialized = null;
1664
1744
  $effect(() => {
1665
1745
  const serialized = JSON.stringify(rowSelectionState);
1746
+ if (lastSelectionSerialized === null) {
1747
+ lastSelectionSerialized = serialized;
1748
+ return;
1749
+ }
1666
1750
  if (serialized === lastSelectionSerialized)
1667
1751
  return;
1668
1752
  lastSelectionSerialized = serialized;
@@ -1684,16 +1768,23 @@ export function createSvGridController(rawProps, domIdBase) {
1684
1768
  // Forward cell-selection rectangle changes to the consumer. Same
1685
1769
  // dedupe pattern - fires only when the serialized rectangle changes
1686
1770
  // so consumers don't see spurious callbacks during re-renders.
1687
- let lastCellRangeSerialized = "";
1771
+ // Every rectangle, the committed ones a Ctrl+click added and the active one
1772
+ // last, the order getSelectionRects gives: a consumer's status bar and
1773
+ // shading cover the whole selection, not only its last range.
1774
+ const currentCellRanges = () => getSelectionRects().map((r) => [r.minRow, r.minCol, r.maxRow, r.maxCol]);
1775
+ // `null` until the first run, which records the starting selection rather
1776
+ // than reporting it: mounting is not a change. (Not seeded eagerly -
1777
+ // getSelectionRects is bound further down this file.)
1778
+ let lastCellRangeSerialized = null;
1688
1779
  $effect(() => {
1689
- // Every rectangle, the committed ones a Ctrl+click added and the
1690
- // active one last, the order getSelectionRects gives: a consumer's
1691
- // status bar and shading cover the whole selection, not only its last
1692
- // range. Depend on selectionRanges so a change to the committed set
1693
- // re-runs this too.
1780
+ // Depend on selectionRanges so a change to the committed set re-runs this too.
1694
1781
  void selectionRanges;
1695
- const ranges = getSelectionRects().map((r) => [r.minRow, r.minCol, r.maxRow, r.maxCol]);
1782
+ const ranges = currentCellRanges();
1696
1783
  const serialized = JSON.stringify(ranges);
1784
+ if (lastCellRangeSerialized === null) {
1785
+ lastCellRangeSerialized = serialized;
1786
+ return;
1787
+ }
1697
1788
  if (serialized === lastCellRangeSerialized)
1698
1789
  return;
1699
1790
  lastCellRangeSerialized = serialized;
@@ -2498,25 +2589,39 @@ export function createSvGridController(rawProps, domIdBase) {
2498
2589
  }
2499
2590
  // Forward sort-clause changes to the consumer. Same dedupe pattern as the
2500
2591
  // selection callback above - fires only when the serialized clauses change.
2501
- let lastSortingSerialized = "";
2592
+ // Seeded from the starting sort, so mounting is not reported as a change:
2593
+ // it used to be, and a consumer that keeps the sort in the URL (reset the
2594
+ // page on a new sort) navigated away from ?page=2 on every load, while a
2595
+ // server data source refetched page 0 for nothing.
2596
+ const currentSorting = () => (grid.getState().sorting ?? []);
2597
+ let lastSortingSerialized = JSON.stringify(untrack(currentSorting));
2502
2598
  $effect(() => {
2503
2599
  gridStateVersion;
2504
- const sorting = (grid.getState().sorting ?? []);
2600
+ const sorting = currentSorting();
2505
2601
  const serialized = JSON.stringify(sorting);
2506
2602
  if (serialized === lastSortingSerialized)
2507
2603
  return;
2508
2604
  lastSortingSerialized = serialized;
2509
2605
  props.onSortingChange?.(sorting);
2510
2606
  });
2607
+ // A row model starts unsorted and takes no initial sort of its own. It
2608
+ // learned `initialSorting` from the mount report above; now that there is
2609
+ // none, hand the starting sort over directly - to each model the grid is
2610
+ // given, and only when there is a sort to hand over.
2611
+ $effect(() => {
2612
+ const model = rawProps.rowModel;
2613
+ untrack(() => {
2614
+ const sorting = currentSorting();
2615
+ if (model?.setSort && sorting.length)
2616
+ model.setSort(sorting);
2617
+ });
2618
+ });
2511
2619
  // Forward filter-state changes to the consumer. Consolidates the three
2512
2620
  // wrapper-managed filter stores (global text, per-column operator filters,
2513
2621
  // facet checklists) into one shape so server-side consumers can build a
2514
2622
  // single query. Skipped entirely when no callback is registered to avoid
2515
2623
  // serializing on every keystroke.
2516
- let lastFiltersSerialized = "";
2517
- $effect(() => {
2518
- if (!props.onFiltersChange)
2519
- return;
2624
+ function currentFilters() {
2520
2625
  const menuEntries = Object.entries(filterMenuValues)
2521
2626
  .filter(([, f]) => {
2522
2627
  if (f.operator === "isBlank" || f.operator === "isNotBlank")
@@ -2549,10 +2654,17 @@ export function createSvGridController(rawProps, domIdBase) {
2549
2654
  ? { ...existing, selectedValues: entry.selectedValues }
2550
2655
  : entry);
2551
2656
  }
2552
- const payload = {
2657
+ return {
2553
2658
  global: globalFilter,
2554
2659
  columns: Array.from(merged.values()),
2555
2660
  };
2661
+ }
2662
+ // Seeded from the starting filters (empty), so mounting is not a change.
2663
+ let lastFiltersSerialized = JSON.stringify(untrack(currentFilters));
2664
+ $effect(() => {
2665
+ if (!props.onFiltersChange)
2666
+ return;
2667
+ const payload = currentFilters();
2556
2668
  const serialized = JSON.stringify(payload);
2557
2669
  if (serialized === lastFiltersSerialized)
2558
2670
  return;
@@ -2712,6 +2824,33 @@ export function createSvGridController(rawProps, domIdBase) {
2712
2824
  const frozenRowList = $derived(frozenRowCount ? allRows.slice(0, frozenRowCount) : []);
2713
2825
  /** The merged cells, indexed; null (the usual case) costs nothing. */
2714
2826
  const mergeIndex = $derived(buildMergeIndex(props.mergedCells));
2827
+ /**
2828
+ * Rows laid out as flex lines rather than table rows (SvGrid.css,
2829
+ * `.sv-grid-table-flex`): a change to one row's cells no longer lays out
2830
+ * every other row. A merged cell spanning rows needs the table algorithm,
2831
+ * so any such merge, or `rowLayout: 'table'`, keeps table layout.
2832
+ */
2833
+ const flexRows = $derived(props.rowLayout !== "table" &&
2834
+ !(props.mergedCells ?? []).some((m) => (m.rowSpan ?? 1) > 1));
2835
+ // The spreadsheetLayout action (a use: directive on a wrapper, outside this
2836
+ // component) switches the table back to table layout on the DOM, with
2837
+ // data-row-layout="table". Watch for it, so the shortcuts that only hold
2838
+ // under flex rows (no right spacer) switch off with it.
2839
+ let tableLayoutForced = $state(false);
2840
+ $effect(() => {
2841
+ const el = gridRootEl;
2842
+ if (!el || typeof MutationObserver === "undefined")
2843
+ return;
2844
+ const read = () => {
2845
+ tableLayoutForced = el.getAttribute("data-row-layout") === "table";
2846
+ };
2847
+ read();
2848
+ const observer = new MutationObserver(read);
2849
+ observer.observe(el, { attributes: true, attributeFilter: ["data-row-layout"] });
2850
+ return () => observer.disconnect();
2851
+ });
2852
+ /** Rows really are flex lines: flexRows, and no spreadsheetLayout forcing table layout. */
2853
+ const flexLayoutActive = $derived(flexRows && !tableLayoutForced);
2715
2854
  const frozenBandHeight = $derived.by(() => {
2716
2855
  if (!frozenRowCount)
2717
2856
  return 0;
@@ -2859,52 +2998,210 @@ export function createSvGridController(rawProps, domIdBase) {
2859
2998
  return item;
2860
2999
  });
2861
3000
  };
3001
+ /**
3002
+ * The column window before any measurement: the columns that fit an
3003
+ * estimated viewport (the measured width once known, else 1,920 px) plus the
3004
+ * overscan, anchored at column 0. Deterministic, so a server render and the
3005
+ * first client render agree. It used to be every column, which put 10,000
3006
+ * columns' cells in the DOM for one frame and cost most of a wide mount.
3007
+ */
3008
+ const preMeasureColumnItems = () => {
3009
+ const viewport = viewportWidth > 0 ? viewportWidth : 1920;
3010
+ let extra = Math.max(props.columnOverscan ?? COLUMN_OVERSCAN, 0);
3011
+ const items = [];
3012
+ let start = 0;
3013
+ for (let i = 0; i < allColumns.length; i += 1) {
3014
+ if (start >= viewport && extra-- <= 0)
3015
+ break;
3016
+ const size = getColumnWidth(allColumns[i].id);
3017
+ items.push({ index: i, key: i, size, start, end: start + size });
3018
+ start += size;
3019
+ }
3020
+ return items;
3021
+ };
2862
3022
  const renderedColumnItems = $derived.by(function renderedColumnItems_d() {
2863
3023
  if (!columnVirtualizationEnabled)
2864
3024
  return allColumnItems();
2865
3025
  // Column virtualization is ON by default and its virtualizer, like the row
2866
3026
  // one, only learns `count` from an effect - which never runs on a server.
2867
- // Without this the server emitted rows containing no data cells. Render the
2868
- // full set pre-measurement: it is what a crawler should see, it matches the
2869
- // first client render so hydration is clean, and the real window replaces
2870
- // it as soon as the measuring effect lands.
2871
- if (virtualColumns.length === 0 && allColumns.length > 0)
2872
- return allColumnItems();
3027
+ // Without this the server emitted rows containing no data cells. Before a
3028
+ // measurement the window is the columns that fit an estimated viewport.
3029
+ if (virtualColumns.length === 0 && allColumns.length > 0 && effectivePinning.left.length === 0 && effectivePinning.right.length === 0) {
3030
+ return preMeasureColumnItems();
3031
+ }
2873
3032
  // Pinned columns are position:sticky, so they only stay pinned while their
2874
- // cell is in the DOM. Plain column virtualization drops them once they leave
2875
- // the scroll window, and the pinned column vanishes. Because allColumns is
2876
- // ordered [pinnedLeft, unpinned, pinnedRight], we keep the rendered window
2877
- // CONTIGUOUS from the pinned-left prefix (index 0) through the pinned-right
2878
- // suffix (last index) whenever those exist. The pinned cells are then always
2879
- // rendered - the existing single-spacer layout positions everything, so no
2880
- // markup changes are needed. (Cost: with a pinned side, the columns between
2881
- // that edge and the window are also rendered; negligible for typical grids,
2882
- // and correctness beats shaving a few off-screen cells.)
2883
- const window = virtualColumns;
2884
- const hasLeft = effectivePinning.left.length > 0;
2885
- const hasRight = effectivePinning.right.length > 0;
2886
- if ((!hasLeft && !hasRight) || window.length === 0)
2887
- return window;
2888
- const firstIdx = window[0].index;
2889
- const lastIdx = window[window.length - 1].index;
2890
- const startIndex = hasLeft ? 0 : firstIdx;
2891
- const endIndex = hasRight ? allColumns.length - 1 : lastIdx;
2892
- if (startIndex === firstIdx && endIndex === lastIdx)
3033
+ // cell is in the DOM. allColumns is ordered [pinnedLeft, unpinned,
3034
+ // pinnedRight], so the rendered set is three runs: the pinned-left prefix,
3035
+ // the virtualizer's window, and the pinned-right suffix. Each run keeps its
3036
+ // real offsets, and the templates draw a spacer cell for the columns
3037
+ // between two runs (`gap` on `renderedColumns`), so the centre stays
3038
+ // virtualized however many columns sit between the pinned edges.
3039
+ const window = virtualColumns.length > 0 ? virtualColumns : preMeasureColumnItems();
3040
+ const leftCount = effectivePinning.left.length;
3041
+ const rightCount = effectivePinning.right.length;
3042
+ if ((leftCount === 0 && rightCount === 0) || window.length === 0)
2893
3043
  return window;
3044
+ const total = allColumns.length;
2894
3045
  const items = [];
3046
+ // Pinned-left prefix: offsets from 0.
2895
3047
  let offset = 0;
2896
- for (let i = 0; i < startIndex; i += 1)
2897
- offset += getColumnWidth(allColumns[i].id);
2898
- for (let i = startIndex; i <= endIndex; i += 1) {
3048
+ for (let i = 0; i < Math.min(leftCount, total); i += 1) {
2899
3049
  const size = getColumnWidth(allColumns[i].id);
2900
3050
  items.push({ index: i, key: i, size, start: offset, end: offset + size });
2901
3051
  offset += size;
2902
3052
  }
3053
+ // The window, minus any pinned column it already covers.
3054
+ const firstRight = total - rightCount;
3055
+ for (const item of window) {
3056
+ if (item.index < leftCount || item.index >= firstRight)
3057
+ continue;
3058
+ items.push(item);
3059
+ }
3060
+ // Pinned-right suffix: offsets measured back from the virtualizer's total,
3061
+ // the same total the right spacer is computed against.
3062
+ const right = [];
3063
+ let end = virtualColumnTotalSize;
3064
+ if (end <= 0) {
3065
+ // Not measured yet: the virtualizer has no total until its effect runs.
3066
+ for (const column of allColumns)
3067
+ end += getColumnWidth(column.id);
3068
+ }
3069
+ for (let i = total - 1; i >= Math.max(firstRight, leftCount); i -= 1) {
3070
+ const size = getColumnWidth(allColumns[i].id);
3071
+ right.push({ index: i, key: i, size, start: end - size, end });
3072
+ end -= size;
3073
+ }
3074
+ for (let i = right.length - 1; i >= 0; i -= 1)
3075
+ items.push(right[i]);
2903
3076
  return items;
2904
3077
  });
2905
- const renderedColumns = $derived.by(() => renderedColumnItems
2906
- .map((item) => ({ item, column: allColumns[item.index] }))
2907
- .filter(hasRenderedColumn));
3078
+ /**
3079
+ * The rendered columns, each with `gap`: the width of the columns NOT
3080
+ * rendered between it and the previous rendered column. Non-zero only at the
3081
+ * seams of the pinned/centre runs above; every row template draws a spacer
3082
+ * cell of that width before the column, so all rows keep the same slots.
3083
+ */
3084
+ // The entries handed out last time, by column id. A horizontal scroll moves
3085
+ // the window by a column or two per frame; an entry whose column, offset,
3086
+ // width and gap are unchanged is handed out again as the SAME object, so
3087
+ // the keyed `{#each}` in every row leaves those cells alone and only the
3088
+ // columns that entered the window are rendered. A fresh object per column
3089
+ // per frame re-ran every visible cell's effects on every scroll frame.
3090
+ let renderedColumnsPrev = new Map();
3091
+ // The body cells' {#each} key. A virtualized window column is keyed by a
3092
+ // slot, not by its id: a column keeps its slot (and so its <td>) while it
3093
+ // stays in the window, and a column entering the window takes a slot a
3094
+ // leaving column freed. A one-column scroll then hands one cell per row to
3095
+ // the new column, and a thumb drag that jumps to columns far away updates
3096
+ // every cell in place instead of destroying the window's cells and creating
3097
+ // new ones (~800 at a time on a wide grid). There are as many slots as the
3098
+ // widest window so far. Pinned columns, and every column without column
3099
+ // virtualization, keep their id.
3100
+ let columnSlots = new Map();
3101
+ let columnSlotOrder = [];
3102
+ let columnSlotCount = 0;
3103
+ const renderedColumns = $derived.by(() => {
3104
+ const out = [];
3105
+ const next = new Map();
3106
+ const total = allColumns.length;
3107
+ const leftCount = effectivePinning.left.length;
3108
+ const firstRight = total - effectivePinning.right.length;
3109
+ const inWindow = (index) => columnVirtualizationEnabled && index >= leftCount && index < firstRight;
3110
+ // Window columns already holding a slot keep it; the others take the
3111
+ // slots nobody kept, in the order those cells sat in the row, then new
3112
+ // ones. In that order a jump to an unrelated window keeps the cells where
3113
+ // they are, and a step moves only the cells that changed column: Svelte
3114
+ // reorders keyed cells by key order.
3115
+ const slots = new Map();
3116
+ const taken = new Set();
3117
+ for (const item of renderedColumnItems) {
3118
+ const id = allColumns[item.index]?.id;
3119
+ if (id === undefined || !inWindow(item.index))
3120
+ continue;
3121
+ const slot = columnSlots.get(id);
3122
+ if (slot !== undefined) {
3123
+ slots.set(id, slot);
3124
+ taken.add(slot);
3125
+ }
3126
+ }
3127
+ const free = columnSlotOrder.filter((slot) => !taken.has(slot));
3128
+ let nextFree = 0;
3129
+ const order = [];
3130
+ for (const item of renderedColumnItems) {
3131
+ const id = allColumns[item.index]?.id;
3132
+ if (id === undefined || !inWindow(item.index))
3133
+ continue;
3134
+ let slot = slots.get(id);
3135
+ if (slot === undefined) {
3136
+ slot = nextFree < free.length ? free[nextFree++] : columnSlotCount++;
3137
+ slots.set(id, slot);
3138
+ }
3139
+ order.push(slot);
3140
+ }
3141
+ // Slots nobody took this time stay at the end, for the next window.
3142
+ for (let i = nextFree; i < free.length; i += 1)
3143
+ order.push(free[i]);
3144
+ columnSlots = slots;
3145
+ columnSlotOrder = order;
3146
+ // What isColumnPinned answers, once per column here instead of once per
3147
+ // cell in the template.
3148
+ const pinnedLeft = new Set(effectivePinning.left);
3149
+ const pinnedRight = new Set(effectivePinning.right);
3150
+ let prevEnd = null;
3151
+ for (const item of renderedColumnItems) {
3152
+ const column = allColumns[item.index];
3153
+ if (column === undefined)
3154
+ continue;
3155
+ let gap = 0;
3156
+ if (prevEnd !== null && item.start - prevEnd >= 0.5)
3157
+ gap = item.start - prevEnd;
3158
+ prevEnd = item.end;
3159
+ const slot = slots.get(column.id);
3160
+ const key = slot !== undefined ? `s:${slot}` : `c:${column.id}`;
3161
+ const pinned = pinnedLeft.has(column.id) ? "left" : pinnedRight.has(column.id) ? "right" : null;
3162
+ const prev = renderedColumnsPrev.get(column.id);
3163
+ const entry = prev &&
3164
+ prev.column === column &&
3165
+ prev.gap === gap &&
3166
+ prev.key === key &&
3167
+ prev.pinned === pinned &&
3168
+ prev.item.index === item.index &&
3169
+ prev.item.start === item.start &&
3170
+ prev.item.size === item.size
3171
+ ? prev
3172
+ : { item, column, gap, key, pinned };
3173
+ next.set(column.id, entry);
3174
+ out.push(entry);
3175
+ }
3176
+ renderedColumnsPrev = next;
3177
+ return out;
3178
+ });
3179
+ /**
3180
+ * The contiguous runs of rendered column indices, for whoever must not span
3181
+ * across a seam: a merged cell or a column-group header is clipped to the
3182
+ * run it starts in. One run unless pinned columns split the window.
3183
+ */
3184
+ const renderedColumnRuns = $derived.by(function renderedColumnRuns_d() {
3185
+ const runs = [];
3186
+ for (const rendered of renderedColumns) {
3187
+ const i = rendered.item.index;
3188
+ const run = runs[runs.length - 1];
3189
+ if (run && i === run.last + 1 && rendered.gap === 0)
3190
+ run.last = i;
3191
+ else
3192
+ runs.push({ first: i, last: i, gap: runs.length === 0 ? 0 : rendered.gap });
3193
+ }
3194
+ return runs;
3195
+ });
3196
+ /** The run holding column `colIndex`, or the whole window if none does. */
3197
+ function columnRunOf(colIndex) {
3198
+ const runs = renderedColumnRuns;
3199
+ for (const run of runs) {
3200
+ if (colIndex >= run.first && colIndex <= run.last)
3201
+ return { firstCol: run.first, lastCol: run.last };
3202
+ }
3203
+ return { firstCol: runs[0]?.first ?? 0, lastCol: runs[runs.length - 1]?.last ?? -1 };
3204
+ }
2908
3205
  const totalColumnWidth = $derived.by(function totalColumnWidth_d() {
2909
3206
  if (columnVirtualizationEnabled)
2910
3207
  return virtualColumnTotalSize;
@@ -2950,25 +3247,37 @@ export function createSvGridController(rawProps, domIdBase) {
2950
3247
  const base = groupHeaderRows;
2951
3248
  if (!columnVirtualizationEnabled || base.length === 0)
2952
3249
  return base;
2953
- const items = renderedColumnItems;
2954
- if (items.length === 0)
3250
+ const runs = renderedColumnRuns;
3251
+ if (runs.length === 0)
2955
3252
  return base;
2956
- const winFirst = items[0].index;
2957
- const winLast = items[items.length - 1].index; // inclusive
3253
+ // Clip each cell to every run it overlaps. With pinned columns the window
3254
+ // is several runs, so a group spanning a seam becomes one cell per run,
3255
+ // and the first cell of each later run carries the seam's gap.
2958
3256
  return base.map((row) => {
2959
3257
  const cells = [];
2960
- for (const cell of row.cells) {
2961
- const start = cell.firstLeafIndex;
2962
- const end = start + cell.colSpan; // exclusive
2963
- const from = Math.max(start, winFirst);
2964
- const to = Math.min(end, winLast + 1);
2965
- if (to <= from)
2966
- continue; // cell is entirely outside the window
2967
- let widthPx = 0;
2968
- for (let i = from; i < to; i += 1)
2969
- widthPx += getColumnWidth(allColumns[i].id);
2970
- cells.push({ ...cell, colSpan: to - from, widthPx, firstLeafIndex: from });
2971
- }
3258
+ runs.forEach((run, runIndex) => {
3259
+ let first = true;
3260
+ for (const cell of row.cells) {
3261
+ const start = cell.firstLeafIndex;
3262
+ const end = start + cell.colSpan; // exclusive
3263
+ const from = Math.max(start, run.first);
3264
+ const to = Math.min(end, run.last + 1);
3265
+ if (to <= from)
3266
+ continue; // cell is entirely outside this run
3267
+ let widthPx = 0;
3268
+ for (let i = from; i < to; i += 1)
3269
+ widthPx += getColumnWidth(allColumns[i].id);
3270
+ cells.push({
3271
+ ...cell,
3272
+ key: runIndex === 0 ? cell.key : `${cell.key}@${runIndex}`,
3273
+ colSpan: to - from,
3274
+ widthPx,
3275
+ firstLeafIndex: from,
3276
+ gapBefore: first && runIndex > 0 ? run.gap : 0,
3277
+ });
3278
+ first = false;
3279
+ }
3280
+ });
2972
3281
  return { ...row, cells };
2973
3282
  });
2974
3283
  });
@@ -3354,9 +3663,13 @@ export function createSvGridController(rawProps, domIdBase) {
3354
3663
  // instead of `scrollVersion` so this effect does NOT re-run on every
3355
3664
  // scroll event - which would otherwise re-call setOptions hundreds of
3356
3665
  // times during a drag.
3357
- viewportVersion;
3358
- const viewportHeight = typeof props.containerHeight === "string"
3359
- ? (scrollContainer?.clientHeight ?? 520)
3666
+ // The measured height comes from the `viewportHeight` derived, which
3667
+ // reads clientHeight only when viewportVersion moves. Reading it here
3668
+ // directly did so on every run of this effect - each filter, sort or
3669
+ // data change - in the middle of the DOM update, forcing a layout the
3670
+ // frame then repeated (6 ms of a 100k-row filter).
3671
+ const measuredHeight = typeof props.containerHeight === "string"
3672
+ ? (scrollContainer ? viewportHeight : 520)
3360
3673
  : (props.containerHeight ?? 520);
3361
3674
  const rh = props.rowHeight;
3362
3675
  // Auto height: read measured sizes, falling back to the fixed rowHeight as
@@ -3384,7 +3697,7 @@ export function createSvGridController(rawProps, domIdBase) {
3384
3697
  count: allRows.length,
3385
3698
  estimateSize,
3386
3699
  overscan: props.overscan ?? 8,
3387
- viewportHeight,
3700
+ viewportHeight: measuredHeight,
3388
3701
  });
3389
3702
  });
3390
3703
  // Track size changes of the shell so the virtualizer's viewport, the
@@ -3433,28 +3746,76 @@ export function createSvGridController(rawProps, domIdBase) {
3433
3746
  });
3434
3747
  $effect(() => {
3435
3748
  // Reading columnWidths here registers it as a reactive dependency so
3436
- // the effect re-runs when the user resizes a column. We pass a fresh
3437
- // closure each run; the virtualizer sees a new function reference and
3438
- // re-derives its layout from the current per-column widths.
3439
- columnWidths;
3440
- collapsedColumns;
3749
+ // the effect re-runs when the user resizes a column. The widths are read
3750
+ // once into an array and the size function reads that: passing a size
3751
+ // function makes the virtualizer rebuild every column's offset, and
3752
+ // resolving each width through getColumnWidth inside that rebuild cost
3753
+ // several reactive reads per column - 10,000 times on a wide grid.
3754
+ // getColumnWidth's rules, with the maps read once: collapsed is 0, a
3755
+ // fitted width wins unless the user resized the column, then the user's
3756
+ // width, and otherwise getColumnBaseWidth (the definition or the default,
3757
+ // exactly as the cells are sized).
3758
+ const resized = columnWidths;
3759
+ const collapsed = collapsedColumns;
3760
+ const fitted = fittedColumnWidths;
3761
+ const widths = new Float64Array(allColumns.length);
3762
+ for (let i = 0; i < widths.length; i++) {
3763
+ const id = allColumns[i].id;
3764
+ const own = resized[id];
3765
+ widths[i] = collapsed[id]
3766
+ ? 0
3767
+ : own !== undefined
3768
+ ? own
3769
+ : (fitted?.[id] ?? getColumnBaseWidth(id));
3770
+ }
3771
+ // `columnOverscan` columns stay rendered ahead of a scroll at all times.
3772
+ // The window does not move on every column boundary: it stays put while
3773
+ // that many columns are left ahead, then moves COLUMN_WINDOW_STEP
3774
+ // columns at once (overscanMin). A move costs about the same whatever
3775
+ // the number of columns entering, in every rendered row, so moving four
3776
+ // at a time did the same scroll for about half the work (1,000 columns,
3777
+ // 120 px per frame, interleaved A/B), and the frames in between change
3778
+ // nothing. Three, not four: a move is one longer frame, and three gave
3779
+ // the shortest longest frame of a wheel scroll (production build: 8.0 ms
3780
+ // against 10.2 at four and 9.4 at two) for about the same total work.
3781
+ const minAhead = Math.max(props.columnOverscan ?? COLUMN_OVERSCAN, 0);
3441
3782
  columnVirtualizer.setOptions({
3442
- count: allColumns.length,
3443
- estimateSize: (index) => {
3444
- const column = allColumns[index];
3445
- return column ? getColumnWidth(column.id) : (props.columnWidth ?? 140);
3446
- },
3447
- overscan: props.columnOverscan ?? 3,
3448
- viewportHeight: viewportWidth,
3783
+ count: widths.length,
3784
+ estimateSize: (index) => widths[index] ?? (props.columnWidth ?? 140),
3785
+ overscan: minAhead + COLUMN_WINDOW_STEP - 1,
3786
+ // One column behind the scroll: the columns the scroll is leaving were
3787
+ // just passed, and a reversal needs one at most for the frame before
3788
+ // the window turns around.
3789
+ overscanBehind: Math.min(1, minAhead),
3790
+ overscanMin: minAhead,
3449
3791
  });
3450
3792
  });
3793
+ // The viewport on its own: a width change re-windows the columns without
3794
+ // rebuilding their offsets.
3451
3795
  $effect(() => {
3452
- if (!scrollContainer)
3796
+ columnVirtualizer.setViewportWidth(viewportWidth);
3797
+ });
3798
+ // Re-sync the virtualizers to the scroll position. This effect re-runs on
3799
+ // anything domToLogicalRowOffset reads (the row count among them), and
3800
+ // reading scrollTop / scrollLeft in the middle of that update forced a
3801
+ // layout the frame then did again (5 ms of a 100k-row filter). The live
3802
+ // position is read once per container; after that the last position the
3803
+ // scroll handler or a programmatic scroll recorded (pendingScrollTop /
3804
+ // pendingScrollLeft) is the same number without the layout.
3805
+ let scrollSyncedContainer = null;
3806
+ $effect(() => {
3807
+ const el = scrollContainer;
3808
+ if (!el)
3453
3809
  return;
3810
+ if (el !== scrollSyncedContainer) {
3811
+ scrollSyncedContainer = el;
3812
+ pendingScrollTop = el.scrollTop;
3813
+ pendingScrollLeft = el.scrollLeft;
3814
+ }
3454
3815
  if (rowVirtualizationEnabled)
3455
- virtualizer.setScrollOffset(domToLogicalRowOffset(scrollContainer.scrollTop));
3816
+ virtualizer.setScrollOffset(domToLogicalRowOffset(pendingScrollTop));
3456
3817
  if (columnVirtualizationEnabled)
3457
- columnVirtualizer.setHorizontalOffset(scrollContainer.scrollLeft);
3818
+ columnVirtualizer.setHorizontalOffset(pendingScrollLeft);
3458
3819
  });
3459
3820
  // Re-arms once the user scrolls away from the bottom, so a long lazy-load
3460
3821
  // run only fires `onScrollBottomReached` once per arrival at the end.
@@ -3982,6 +4343,8 @@ export function createSvGridController(rawProps, domIdBase) {
3982
4343
  get columnWidths() { return columnWidths; },
3983
4344
  get collapsedColumns() { return collapsedColumns; },
3984
4345
  get mergeIndex() { return mergeIndex; },
4346
+ get flexRows() { return flexRows; },
4347
+ get flexLayoutActive() { return flexLayoutActive; },
3985
4348
  get setColumnCollapsed() { return setColumnCollapsed; },
3986
4349
  set columnWidths(v) { columnWidths = v; },
3987
4350
  get MIN_COLUMN_WIDTH() { return MIN_COLUMN_WIDTH; },
@@ -4453,6 +4816,8 @@ export function createSvGridController(rawProps, domIdBase) {
4453
4816
  get renderedColumnItems() { return renderedColumnItems; },
4454
4817
  get hasRenderedColumn() { return hasRenderedColumn; },
4455
4818
  get renderedColumns() { return renderedColumns; },
4819
+ get renderedColumnRuns() { return renderedColumnRuns; },
4820
+ get columnRunOf() { return columnRunOf; },
4456
4821
  get totalColumnWidth() { return totalColumnWidth; },
4457
4822
  get hasHorizontalOverflow() { return hasHorizontalOverflow; },
4458
4823
  get columnWindowStart() { return columnWindowStart; },