@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
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The headless docs tell you to write:
3
+ *
4
+ * const table = createSvGrid({ ..., state: { sorting }, onSortingChange })
5
+ * const rows = $derived(table.getRowModel().rows)
6
+ *
7
+ * `createStore` (core.ts) is a plain closure + listener set, NOT runes, and
8
+ * `options.state` is spread into the initial store once at creation. So that
9
+ * `$derived` has no reactive dependency on the engine's state: clicking a
10
+ * header updates the store and the component's own `sorting` rune, the sort
11
+ * indicator moves, and the ROWS never recompute.
12
+ *
13
+ * Caught while recording the headless video: the arrow cleared and the order
14
+ * did not change. Lives in a `.svelte.test.ts` so `$state` is available.
15
+ */
16
+ import { describe, expect, it } from 'vitest'
17
+ import { flushSync } from 'svelte'
18
+ import {
19
+ createSvGrid,
20
+ createCoreRowModel,
21
+ createSortedRowModel,
22
+ tableFeatures,
23
+ rowSortingFeature,
24
+ } from './index'
25
+
26
+ type Repo = { name: string; stars: number }
27
+
28
+ // Deliberately NOT in stars order: clearing the sort has to produce a
29
+ // visibly different list, or the assertions below prove nothing.
30
+ const data: Repo[] = [
31
+ { name: 'sv-grid', stars: 172 },
32
+ { name: 'svelte', stars: 78000 },
33
+ { name: 'vite', stars: 68000 },
34
+ ]
35
+
36
+ const features = tableFeatures({ rowSortingFeature })
37
+ const columns = [
38
+ { field: 'name', header: 'Repo' },
39
+ { field: 'stars', header: 'Stars' },
40
+ ] as never
41
+
42
+ function build(onSortingChange: (next: unknown) => void, seed: Array<{ id: string; desc: boolean }>) {
43
+ return createSvGrid({
44
+ _features: features,
45
+ _rowModels: {
46
+ coreRowModel: createCoreRowModel<Repo>(),
47
+ sortedRowModel: createSortedRowModel<Repo>(),
48
+ },
49
+ data,
50
+ columns,
51
+ state: { sorting: seed },
52
+ onSortingChange,
53
+ } as never)
54
+ }
55
+
56
+ describe('headless state reactivity', () => {
57
+ it('seeds the sort from options.state', () => {
58
+ const table = build(() => {}, [{ id: 'stars', desc: true }])
59
+ const names = table.getRowModel().rows.map((r) => (r.original as Repo).name)
60
+ expect(names).toEqual(['svelte', 'vite', 'sv-grid'])
61
+ })
62
+
63
+ it('re-sorts when the toggle handler runs', () => {
64
+ let sorting = $state([{ id: 'stars', desc: true }])
65
+ const table = build((next) => (sorting = next as typeof sorting), sorting)
66
+
67
+ // The pattern the headless docs now teach: touch the state rune the
68
+ // component owns so the derived re-runs. A plain
69
+ // `$derived(table.getRowModel().rows)` reads only the framework-free
70
+ // engine and never recomputes, which is the bug this guards.
71
+ const rows = $derived.by(() => {
72
+ void sorting
73
+ return table.getRowModel().rows
74
+ })
75
+ expect(rows.map((r) => (r.original as Repo).name)).toEqual(['svelte', 'vite', 'sv-grid'])
76
+
77
+ const stars = table.getAllColumns().find((c) => c.id === 'stars')!
78
+ stars.getToggleSortingHandler()()
79
+ flushSync()
80
+
81
+ // The engine's own state really did change...
82
+ expect(table.getState().sorting).toEqual([])
83
+ // ...and the component's rune did too, which is why the arrow clears.
84
+ expect(sorting).toEqual([])
85
+
86
+ // The rows must follow the cleared sort, i.e. fall back to data order.
87
+ expect(rows.map((r) => (r.original as Repo).name)).toEqual(['sv-grid', 'svelte', 'vite'])
88
+ })
89
+ })
package/src/headless.ts CHANGED
@@ -84,7 +84,7 @@ export {
84
84
 
85
85
  export { createGrid, createSvGrid, type SvelteGrid } from './createGrid.svelte'
86
86
  export { createGridState, createSvGridState } from './createGridState.svelte'
87
- export { subscribeGrid, subscribeSvGrid } from './subscribe'
87
+ export { subscribeGrid, subscribeSvGrid } from './subscribe.svelte'
88
88
 
89
89
  // The virtualizers are pure window arithmetic (scroll offset in, visible
90
90
  // range out) with no DOM of their own, and a custom renderer over 50k rows
package/src/index.ts CHANGED
@@ -73,7 +73,7 @@ export {
73
73
 
74
74
  export { createGrid, createSvGrid, type SvelteGrid } from './createGrid.svelte'
75
75
  export { createGridState, createSvGridState } from './createGridState.svelte'
76
- export { subscribeGrid, subscribeSvGrid } from './subscribe'
76
+ export { subscribeGrid, subscribeSvGrid } from './subscribe.svelte'
77
77
  export { default as SvGrid } from './SvGrid.svelte'
78
78
  // Kanban board view. The `board` prop + its config types live in the free grid,
79
79
  // but the RENDERER (SvGridBoard) ships in @svgrid/enterprise and registers itself
@@ -801,6 +801,12 @@ export {
801
801
  type BlockFetchResult,
802
802
  type BlockState,
803
803
  } from './server-block-cache'
804
+ export {
805
+ createWindowedData,
806
+ windowedSourceOf,
807
+ isWindowedData,
808
+ type WindowedSource,
809
+ } from './windowed-data'
804
810
  export {
805
811
  toServerFilterColumns,
806
812
  type GridRowModel,
@@ -216,7 +216,11 @@ export function createKeyboard<
216
216
  }
217
217
  }
218
218
  if (column?.columnDef.editorType === "checkbox") {
219
- ctx.toggleBooleanCell(current.rowIndex, current.colIndex);
219
+ // Same rule as a click: `editable: false` (or an editable callback
220
+ // saying no) keeps the checkbox read-only.
221
+ if (ctx.isCellEditableAt?.(current.rowIndex, current.colIndex) ?? true) {
222
+ ctx.toggleBooleanCell(current.rowIndex, current.colIndex);
223
+ }
220
224
  return;
221
225
  }
222
226
  ctx.onCellDoubleClick(current.rowIndex, current.colIndex);
@@ -70,6 +70,8 @@ describe('createSvGrid - getRowId', () => {
70
70
  },
71
71
  }).getRowModel().rows
72
72
  expect(rows).toHaveLength(data.length)
73
+ // Rows are made when read (a first build is lazy), so read each one.
74
+ expect(rows.map((r) => r.id)).toEqual(['ada', 'linus', 'grace'])
73
75
  expect(seen).toEqual([
74
76
  { id: 'ada', index: 0 },
75
77
  { id: 'linus', index: 1 },
@@ -77,6 +79,28 @@ describe('createSvGrid - getRowId', () => {
77
79
  ])
78
80
  })
79
81
 
82
+ it('makes a row, and asks getRowId for it, only when the row is read', () => {
83
+ // A first build is lazy: a grid that only shows its first screen must not
84
+ // build a row object (or call getRowId) for every row of a large dataset.
85
+ const seen: number[] = []
86
+ const rows = createSvGrid<typeof features, Row>({
87
+ _features: features,
88
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
89
+ columns,
90
+ data,
91
+ getRowId: (row, index) => {
92
+ seen.push(index)
93
+ return row.id
94
+ },
95
+ }).getRowModel().rows
96
+ expect(seen).toEqual([])
97
+ expect(rows[2]!.id).toBe('grace')
98
+ expect(seen).toEqual([2])
99
+ // The same object every time it is read.
100
+ expect(rows[2]).toBe(rows[2])
101
+ expect(seen).toEqual([2])
102
+ })
103
+
80
104
  it('preserves the same id across `getRowModel()` invocations (stable identity)', () => {
81
105
  const grid = createSvGrid<typeof features, Row>({
82
106
  _features: features,
@@ -100,6 +100,20 @@ describe('createSelection - row selection', () => {
100
100
  expect(sel.isRowSelected('missing')).toBe(false)
101
101
  })
102
102
 
103
+ it('with a selection model, a drawn row is answered from its own data, not a scan of every row', () => {
104
+ const find = vi.fn(() => makeDataRow('r1', { id: 'r1' }))
105
+ const model = { isSelected: vi.fn((id: string, data: any) => data?.id === id), toggle: vi.fn() }
106
+ const ctx = makeCtx({ allRows: { find }, props: { rowSelectionModel: model } })
107
+ const sel = createSelection(ctx)
108
+ expect(sel.isRowSelected('r1', { id: 'r1' })).toBe(true)
109
+ sel.toggleRowSelectionById('r1', { id: 'r1' })
110
+ expect(model.toggle).toHaveBeenCalledWith('r1', { id: 'r1' }, false)
111
+ expect(find).not.toHaveBeenCalled()
112
+ // Without the data (an API caller) it still finds the row by id.
113
+ expect(sel.isRowSelected('r1')).toBe(true)
114
+ expect(find).toHaveBeenCalledTimes(1)
115
+ })
116
+
103
117
  it('toggleRowSelectionById flips the single row', () => {
104
118
  const ctx = makeCtx({ rowSelectionState: { r1: false } })
105
119
  const sel = createSelection(ctx)
@@ -657,6 +671,43 @@ describe('createSelection - onCellClick', () => {
657
671
  expect(ctx.selectionRange.anchor).toEqual({ rowIndex: 0, colIndex: 0 })
658
672
  })
659
673
 
674
+ describe('boolean cells that are not declared checkboxes', () => {
675
+ const boolCtx = (over: any = {}, columnDef: any = {}) =>
676
+ clickCtx({
677
+ allRows: [makeDataRow('r0', { done: true })],
678
+ allColumns: [makeColumn('done', { field: 'done', ...columnDef })],
679
+ ...over,
680
+ })
681
+
682
+ it('do not toggle (or write the row) while inline editing is off', () => {
683
+ const ctx = boolCtx({ editingEnabled: false })
684
+ createSelection(ctx).onCellClick(0, 0)
685
+ expect(ctx.toggleBooleanCell).not.toHaveBeenCalled()
686
+ expect(ctx.selectionRange.anchor).toEqual({ rowIndex: 0, colIndex: 0 })
687
+ })
688
+
689
+ it('toggle when inline editing is on and the cell is editable', () => {
690
+ const ctx = boolCtx({ editingEnabled: true, isCellEditableAt: () => true })
691
+ createSelection(ctx).onCellClick(0, 0)
692
+ expect(ctx.toggleBooleanCell).toHaveBeenCalledWith(0, 0)
693
+ })
694
+
695
+ it('do not toggle when the column has its own cell renderer', () => {
696
+ const ctx = boolCtx({ editingEnabled: true, isCellEditableAt: () => true }, { cell: () => 'flag' })
697
+ createSelection(ctx).onCellClick(0, 0)
698
+ expect(ctx.toggleBooleanCell).not.toHaveBeenCalled()
699
+ })
700
+ })
701
+
702
+ it('a declared checkbox that is not editable stays put', () => {
703
+ const ctx = clickCtx({
704
+ allColumns: [makeColumn('c0', { editorType: 'checkbox', editable: false })],
705
+ isCellEditableAt: () => false,
706
+ })
707
+ createSelection(ctx).onCellClick(0, 0)
708
+ expect(ctx.toggleBooleanCell).not.toHaveBeenCalled()
709
+ })
710
+
660
711
  it('enters edit mode when clicking the previously-active editable cell', () => {
661
712
  const ctx = clickCtx({
662
713
  editingEnabled: true,
package/src/selection.ts CHANGED
@@ -20,21 +20,33 @@ export function createSelection<
20
20
  TFeatures extends TableFeatures = TableFeatures,
21
21
  TData extends RowData = RowData,
22
22
  >(ctx: any) {
23
- function isRowSelected(rowId: string) {
23
+ /**
24
+ * A selection model needs the row's data. The body passes the row it is
25
+ * drawing as `original`; without it the row is looked up by id, a scan of
26
+ * every row - on a server model's windowed million rows, once per drawn
27
+ * row, that scan held the page for seconds after a sort.
28
+ */
29
+ function rowOriginal(rowId: string): { found: boolean; original?: unknown } {
30
+ const row = ctx.allRows.find((r: any) => r.id === rowId);
31
+ return row ? { found: true, original: row.original } : { found: false };
32
+ }
33
+
34
+ function isRowSelected(rowId: string, original?: unknown) {
24
35
  const model = ctx.props.rowSelectionModel;
25
36
  if (model) {
26
- const row = ctx.allRows.find((r: any) => r.id === rowId);
27
- return row ? model.isSelected(rowId, row.original) : false;
37
+ if (original !== undefined) return model.isSelected(rowId, original);
38
+ const hit = rowOriginal(rowId);
39
+ return hit.found ? model.isSelected(rowId, hit.original) : false;
28
40
  }
29
41
  return Boolean(ctx.rowSelectionState[rowId]);
30
42
  }
31
43
 
32
- function toggleRowSelectionById(rowId: string) {
44
+ function toggleRowSelectionById(rowId: string, original?: unknown) {
33
45
  const model = ctx.props.rowSelectionModel;
34
46
  if (model) {
35
- const row = ctx.allRows.find((r: any) => r.id === rowId);
36
- if (!row) return;
37
- model.toggle(rowId, row.original, !model.isSelected(rowId, row.original));
47
+ const hit = original !== undefined ? { found: true, original } : rowOriginal(rowId);
48
+ if (!hit.found) return;
49
+ model.toggle(rowId, hit.original, !model.isSelected(rowId, hit.original));
38
50
  return;
39
51
  }
40
52
  ctx.grid.setRowSelection((prev: any) => ({ ...prev, [rowId]: !prev[rowId] }));
@@ -472,8 +484,19 @@ export function createSelection<
472
484
 
473
485
  /** Look up a column by id without depending on `buildApi`'s private
474
486
  * closure (those helpers don't exist at this scope). */
487
+ // id -> column, rebuilt when the column list changes. Called per header
488
+ // cell as columns scroll into view, so a linear `find` made every scroll
489
+ // frame cost more the more columns the grid has.
490
+ let columnsById: Map<string, any> = new Map();
491
+ let columnsByIdFor: unknown = null;
475
492
  function findColumnById(columnId: string) {
476
- return ctx.allColumns.find((c: any) => c.id === columnId);
493
+ const all = ctx.allColumns;
494
+ if (all !== columnsByIdFor) {
495
+ columnsById = new Map();
496
+ for (const c of all as any[]) columnsById.set(c.id, c);
497
+ columnsByIdFor = all;
498
+ }
499
+ return columnsById.get(columnId);
477
500
  }
478
501
 
479
502
  function onCellPointerDown(
@@ -655,12 +678,22 @@ export function createSelection<
655
678
  row: row.original as TData,
656
679
  });
657
680
 
658
- const isCheckboxColumn =
659
- column.columnDef.editorType === "checkbox" ||
660
- typeof cellValue === "boolean";
661
-
662
- if (isCheckboxColumn) {
663
- ctx.toggleBooleanCell(rowIndex, colIndex);
681
+ // A declared checkbox editor toggles on click unless the cell is not
682
+ // editable. A value that only HAPPENS to be boolean is drawn as a read-only
683
+ // checkbox, so it toggles only when inline editing is on - it used to flip
684
+ // and write the row on any click, in grids with editing switched off. A
685
+ // column with its own `cell` renderer draws (and handles) the value itself.
686
+ const declaredCheckbox = column.columnDef.editorType === "checkbox";
687
+ const inferredCheckbox =
688
+ !declaredCheckbox &&
689
+ typeof cellValue === "boolean" &&
690
+ column.columnDef.cell == null;
691
+
692
+ if (declaredCheckbox || inferredCheckbox) {
693
+ const editable = ctx.isCellEditableAt?.(rowIndex, colIndex) ?? true;
694
+ if (editable && (declaredCheckbox || ctx.editingEnabled)) {
695
+ ctx.toggleBooleanCell(rowIndex, colIndex);
696
+ }
664
697
  setActiveCell(rowIndex, colIndex);
665
698
  setSelection(rowIndex, colIndex);
666
699
  ctx.editingCell = null;
@@ -30,6 +30,7 @@
30
30
  * Enterprise's server row model runs one of these per group level; the free
31
31
  * flat controller runs exactly one.
32
32
  */
33
+ import { createWindowedData } from './windowed-data'
33
34
 
34
35
  /**
35
36
  * The mark that says "this row is not data yet".
@@ -171,6 +172,13 @@ export type BlockCache<TData> = {
171
172
  setViewport(startIndex: number, endIndex: number): void
172
173
  /** The dense row array to hand the grid. Placeholders fill unloaded slots. */
173
174
  rows(): ReadonlyArray<TData>
175
+ /**
176
+ * The same rows as windowed data (see windowed-data.ts): `length` is the
177
+ * row count and each entry is read through `getRow` when the grid asks for
178
+ * it, so handing it over costs O(1) whatever the count. A new array per
179
+ * change, like `rows()`.
180
+ */
181
+ windowedRows(): ReadonlyArray<TData>
174
182
  /** One row, without building the array. Returns a placeholder when unloaded. */
175
183
  getRow(index: number): TData | typeof LOADING_ROW | typeof FAILED_ROW
176
184
  rowCount(): number | null
@@ -267,6 +275,7 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
267
275
 
268
276
  let emitScheduled = false
269
277
  let rowsCache: TData[] | null = null
278
+ let windowedCache: TData[] | null = null
270
279
 
271
280
  const blockOf = (rowIndex: number): number => Math.floor(rowIndex / blockSize)
272
281
  const startOf = (blockIndex: number): number => blockIndex * blockSize
@@ -321,7 +330,7 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
321
330
  * mutations notifies once. Every mutating path ends here.
322
331
  */
323
332
  function emit(): void {
324
- rowsCache = null // rebuilt lazily, on the next read
333
+ rowsCache = windowedCache = null // rebuilt lazily, on the next read
325
334
  if (disposed || emitScheduled) return
326
335
  emitScheduled = true
327
336
  schedule(() => {
@@ -439,6 +448,14 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
439
448
  }
440
449
  }
441
450
 
451
+ function rowAt(index: number): TData | typeof LOADING_ROW | typeof FAILED_ROW {
452
+ const block = blocks.get(blockOf(index))
453
+ if (!block) return LOADING_ROW
454
+ if (block.status === 'failed') return FAILED_ROW
455
+ const row = block.rows[index - startOf(blockOf(index))]
456
+ return row ?? LOADING_ROW
457
+ }
458
+
442
459
  function abortAll(): void {
443
460
  for (const block of blocks.values()) block.controller?.abort()
444
461
  inFlight = 0
@@ -485,14 +502,14 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
485
502
  return out
486
503
  },
487
504
 
488
- getRow(index) {
489
- const block = blocks.get(blockOf(index))
490
- if (!block) return LOADING_ROW
491
- if (block.status === 'failed') return FAILED_ROW
492
- const row = block.rows[index - startOf(blockOf(index))]
493
- return row ?? LOADING_ROW
505
+ windowedRows() {
506
+ if (windowedCache) return windowedCache
507
+ windowedCache = createWindowedData<TData>(currentLength(), (i) => rowAt(i) as TData)
508
+ return windowedCache
494
509
  },
495
510
 
511
+ getRow: rowAt,
512
+
496
513
  rowCount: () => (countKnown ? count : null),
497
514
  lastRowKnown: () => countKnown,
498
515
 
@@ -531,7 +548,7 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
531
548
  blocks.clear()
532
549
  count = null
533
550
  countKnown = false
534
- rowsCache = null
551
+ rowsCache = windowedCache = null
535
552
  fetchViewport()
536
553
  emit()
537
554
  },
@@ -675,7 +692,7 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
675
692
  controller: null,
676
693
  })
677
694
  }
678
- rowsCache = null
695
+ rowsCache = windowedCache = null
679
696
  }
680
697
 
681
698
  /** Forget every block from `fromBlock` on, aborting any in flight. */
@@ -685,6 +702,6 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
685
702
  block.controller?.abort()
686
703
  blocks.delete(index)
687
704
  }
688
- rowsCache = null
705
+ rowsCache = windowedCache = null
689
706
  }
690
707
  }
@@ -581,7 +581,9 @@ export function createServerDataSource<TData>(
581
581
  */
582
582
  function emitFromCache(): void {
583
583
  if (!cache || disposed) return
584
- const rows = cache.rows()
584
+ // Windowed: the grid reads only the rows it shows, so a block landing
585
+ // costs the same at 100M rows as at 10K.
586
+ const rows = cache.windowedRows()
585
587
  state.rows = rows
586
588
  state.rowCount = cache.rowCount()
587
589
  state.lastRowKnown = cache.lastRowKnown()
package/src/server.ts CHANGED
@@ -41,6 +41,12 @@ export {
41
41
  type BlockFetchResult,
42
42
  type BlockState,
43
43
  } from './server-block-cache'
44
+ export {
45
+ createWindowedData,
46
+ windowedSourceOf,
47
+ isWindowedData,
48
+ type WindowedSource,
49
+ } from './windowed-data'
44
50
  export {
45
51
  toServerFilterColumns,
46
52
  type GridRowModel,
@@ -156,6 +156,12 @@ const OVERLAY_CLASS = 'sv-cell-border-overlay'
156
156
  * every relevant DOM mutation inside the grid, and whenever the action
157
157
  * options update. */
158
158
  function apply(root: HTMLElement, opts: SpreadsheetActionOptions): void {
159
+ // Merges here are colspan / rowspan on the rendered cells, which only the
160
+ // table algorithm honours: keep this grid's rows in table layout (the
161
+ // flex-row rules in SvGrid.css skip a table marked this way).
162
+ for (const table of root.querySelectorAll<HTMLTableElement>('table.sv-grid-table')) {
163
+ if (table.getAttribute('data-row-layout') !== 'table') table.setAttribute('data-row-layout', 'table')
164
+ }
159
165
  // ---- 1. Clean up anything the LAST run added -----------------------
160
166
  for (const td of root.querySelectorAll<HTMLTableCellElement>(`td[${MARK}]`)) {
161
167
  td.removeAttribute('colspan')
@@ -6,7 +6,7 @@
6
6
  * selection actually changed.
7
7
  */
8
8
  import { describe, expect, it } from 'vitest'
9
- import { subscribeGrid, subscribeSvGrid } from './subscribe'
9
+ import { subscribeGrid, subscribeSvGrid } from './subscribe.svelte'
10
10
 
11
11
  type Listener = () => void
12
12
 
@@ -23,7 +23,11 @@ export function subscribeGrid<
23
23
  grid: SvGrid<TData>,
24
24
  selector: (state: Record<string, any>) => TSelected,
25
25
  ) {
26
- let current = selector(grid.store.state)
26
+ // $state, not a plain variable: core.ts keeps a deliberately framework-free
27
+ // store, so this Svelte adapter is where reactivity belongs. Without it a
28
+ // `$derived` reading .current never re-runs and the docs' headless examples
29
+ // render their seeded state forever.
30
+ let current = $state(selector(grid.store.state))
27
31
  grid.store.subscribe(() => {
28
32
  const next = selector(grid.store.state)
29
33
  if (!shallowCompare(current, next)) current = next
@@ -580,6 +580,35 @@ describe('SvGrid - external sort / filter', () => {
580
580
  }
581
581
  })
582
582
 
583
+ it('mounting is not a change: no sort, filter or selection callback fires on mount', async () => {
584
+ // These used to fire once on mount with the starting state. A consumer
585
+ // that keeps the sort in the URL and resets the page on a new sort then
586
+ // navigated from ?page=2 back to page 1 on every load.
587
+ const calls: string[] = []
588
+ const { api, destroy } = await mountGrid({
589
+ externalSort: true,
590
+ externalFilter: true,
591
+ initialSorting: [{ id: 'age', desc: true }],
592
+ onSortingChange: () => calls.push('sort'),
593
+ onFiltersChange: () => calls.push('filters'),
594
+ onRowSelectionChange: () => calls.push('rows'),
595
+ onCellSelectionChange: () => calls.push('cells'),
596
+ })
597
+ try {
598
+ await tick()
599
+ await tick()
600
+ expect(calls).toEqual([])
601
+ // ...and a real change still reports, once each.
602
+ api.setSort('age', 'asc')
603
+ api.setFilter('team', { operator: 'contains', value: 'Research' })
604
+ await tick()
605
+ expect(calls.filter((c) => c === 'sort')).toHaveLength(1)
606
+ expect(calls.filter((c) => c === 'filters')).toHaveLength(1)
607
+ } finally {
608
+ destroy()
609
+ }
610
+ })
611
+
583
612
  it('externalFilter + onFiltersChange fires when a filter is set', async () => {
584
613
  let calls = 0
585
614
  const { api, destroy } = await mountGrid({
@@ -620,6 +649,35 @@ describe('SvGrid - custom snippet cell', () => {
620
649
  destroy()
621
650
  }
622
651
  })
652
+
653
+ it('a boolean column with its own cell renderer draws the renderer, not the built-in checkbox', async () => {
654
+ // The checkbox branch used to run before the `cell` branch, so any boolean
655
+ // value came out as a read-only checkbox and the renderer was never called.
656
+ const flagCols: ColumnDef<typeof fullFeatures, Person>[] = personColumns.map((c) =>
657
+ c.field === 'active'
658
+ ? { ...c, cell: (ctx: any) => ((ctx.row.original as Person).active ? 'ON' : 'OFF') }
659
+ : c,
660
+ )
661
+ const { target, destroy } = await mountGrid({ columns: flagCols, showRowSelection: false })
662
+ try {
663
+ await tick()
664
+ expect(target.querySelectorAll('.sv-grid-checkbox-readonly').length).toBe(0)
665
+ expect(target.textContent).toContain('ON')
666
+ expect(target.textContent).toContain('OFF')
667
+ } finally {
668
+ destroy()
669
+ }
670
+ })
671
+
672
+ it('a boolean column without a renderer still draws the read-only checkbox', async () => {
673
+ const { target, destroy } = await mountGrid({ showRowSelection: false })
674
+ try {
675
+ await tick()
676
+ expect(target.querySelectorAll('.sv-grid-checkbox-readonly').length).toBeGreaterThan(0)
677
+ } finally {
678
+ destroy()
679
+ }
680
+ })
623
681
  })
624
682
 
625
683
  describe('SvGrid - filterMode shortcuts', () => {