@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
@@ -11,6 +11,8 @@ function sameOptions(a: VirtualizerOptions, b: VirtualizerOptions) {
11
11
  a.count === b.count &&
12
12
  a.estimateSize === b.estimateSize &&
13
13
  (a.overscan ?? 6) === (b.overscan ?? 6) &&
14
+ a.overscanBehind === b.overscanBehind &&
15
+ a.overscanMin === b.overscanMin &&
14
16
  a.viewportHeight === b.viewportHeight &&
15
17
  (a.scrollOffset ?? 0) === (b.scrollOffset ?? 0)
16
18
  )
@@ -133,13 +135,14 @@ function buildVariableItems(
133
135
  return items
134
136
  }
135
137
 
136
- function createState(
138
+ /** The items under the viewport at the current scroll offset, before any
139
+ * overscan. */
140
+ function visibleRange(
137
141
  options: VirtualizerOptions,
138
142
  /** Pre-built cumulative offsets for the variable-size path. */
139
143
  offsets: Array<number> | null,
140
- ): VirtualizerState {
144
+ ) {
141
145
  const count = Math.max(options.count, 0)
142
- const overscan = Math.max(options.overscan ?? 6, 0)
143
146
  const viewportHeight = Math.max(options.viewportHeight, 0)
144
147
 
145
148
  if (typeof options.estimateSize === 'function' && offsets) {
@@ -167,38 +170,59 @@ function createState(
167
170
  else hi = mid
168
171
  }
169
172
  const visibleEnd = Math.max(lo - 1, visibleStart)
170
-
171
- const startIndex = count === 0 ? 0 : clamp(visibleStart - overscan, 0, count - 1)
172
- const endIndex = count === 0 ? -1 : clamp(visibleEnd + overscan, 0, count - 1)
173
-
174
- return {
175
- items: endIndex >= startIndex ? buildVariableItems(startIndex, endIndex, offsets) : [],
176
- totalSize,
177
- startIndex,
178
- endIndex,
179
- scrollOffset,
180
- viewportHeight,
181
- }
173
+ return { count, viewportHeight, totalSize, scrollOffset, visibleStart, visibleEnd, uniformSize: 0 }
182
174
  }
183
175
 
184
176
  // Uniform-size fast path (original behavior).
185
- const estimateSize = Math.max(
177
+ const uniformSize = Math.max(
186
178
  typeof options.estimateSize === 'number' ? options.estimateSize : 1,
187
179
  1,
188
180
  )
189
- const totalSize = count * estimateSize
181
+ const totalSize = count * uniformSize
190
182
  const maxOffset = Math.max(totalSize - viewportHeight, 0)
191
183
  const scrollOffset = clamp(options.scrollOffset ?? 0, 0, maxOffset)
192
184
 
193
- const visibleStart = Math.floor(scrollOffset / estimateSize)
194
- const visibleCount = Math.ceil(viewportHeight / estimateSize)
185
+ const visibleStart = Math.floor(scrollOffset / uniformSize)
186
+ const visibleCount = Math.ceil(viewportHeight / uniformSize)
195
187
  const visibleEnd = Math.min(visibleStart + visibleCount, Math.max(count - 1, 0))
188
+ return { count, viewportHeight, totalSize, scrollOffset, visibleStart, visibleEnd, uniformSize }
189
+ }
196
190
 
197
- const startIndex = count === 0 ? 0 : clamp(visibleStart - overscan, 0, count - 1)
198
- const endIndex = count === 0 ? -1 : clamp(visibleEnd + overscan, 0, count - 1)
191
+ function createState(
192
+ options: VirtualizerOptions,
193
+ /** Pre-built cumulative offsets for the variable-size path. */
194
+ offsets: Array<number> | null,
195
+ /** The scroll moved on from the window already rendered. With
196
+ * `overscanMin`, only then does the full `overscan` go ahead; a window
197
+ * built at rest or after a jump carries `overscanMin`, so a thumb drag
198
+ * that jumps every frame renders no more than it needs. */
199
+ scrolling = false,
200
+ ): VirtualizerState {
201
+ const full = Math.max(options.overscan ?? 6, 0)
202
+ const overscan =
203
+ options.overscanMin === undefined || scrolling ? full : clamp(options.overscanMin, 0, full)
204
+ // The overscan goes ahead of the scroll and `overscanBehind` behind it:
205
+ // items behind the movement were just scrolled past, and a horizontal
206
+ // scroll that kept three columns on each side drew ~40% more cells than it
207
+ // needed to (350 vs 275 in the wide benchmark).
208
+ const behind = Math.min(Math.max(options.overscanBehind ?? full, 0), overscan)
209
+ const direction = options.scrollDirection ?? 0
210
+ const overscanBefore = direction > 0 ? behind : overscan
211
+ const overscanAfter = direction < 0 ? behind : overscan
212
+ const { count, viewportHeight, totalSize, scrollOffset, visibleStart, visibleEnd, uniformSize } =
213
+ visibleRange(options, offsets)
214
+
215
+ const startIndex = count === 0 ? 0 : clamp(visibleStart - overscanBefore, 0, count - 1)
216
+ const endIndex = count === 0 ? -1 : clamp(visibleEnd + overscanAfter, 0, count - 1)
199
217
 
218
+ let items: Array<VirtualItem> = []
219
+ if (endIndex >= startIndex) {
220
+ items = uniformSize > 0
221
+ ? buildUniformItems(startIndex, endIndex, uniformSize)
222
+ : buildVariableItems(startIndex, endIndex, offsets!)
223
+ }
200
224
  return {
201
- items: endIndex >= startIndex ? buildUniformItems(startIndex, endIndex, estimateSize) : [],
225
+ items,
202
226
  totalSize,
203
227
  startIndex,
204
228
  endIndex,
@@ -251,13 +275,52 @@ export function createVirtualizer(initial: VirtualizerOptions) {
251
275
  listeners.forEach((listener) => listener())
252
276
  }
253
277
 
254
- function recalc() {
255
- const next = createState(options, getOffsets())
278
+ function recalc(scrolling = false) {
279
+ const next = createState(options, getOffsets(), scrolling)
256
280
  if (sameState(state, next)) return
281
+ // Hand back the previous item object for a row whose index, offset, size
282
+ // and slot did not change. The grid's row {#each} is keyed by slot, so a
283
+ // row that stays in the window keeps its <tr>, but a new item object
284
+ // still invalidated everything that row and its cells derive from it:
285
+ // every scroll frame re-checked ~300 cells to render the two that came
286
+ // into view, and Svelte's dependency marking was most of the frame's
287
+ // script time (measured 4.8 of 8.3 ms on a 100k-row grid).
288
+ if (state.items.length > 0 && next.items.length > 0) {
289
+ const first = state.items[0]!.index
290
+ const items = next.items
291
+ for (let i = 0; i < items.length; i += 1) {
292
+ const item = items[i]!
293
+ const prev = state.items[item.index - first]
294
+ if (prev && prev.index === item.index && prev.start === item.start && prev.size === item.size && prev.key === item.key) {
295
+ items[i] = prev
296
+ }
297
+ }
298
+ }
257
299
  state = next
258
300
  emit()
259
301
  }
260
302
 
303
+ /**
304
+ * What a scroll does to the window already rendered, with `overscanMin`:
305
+ * 'hold' while that window still covers the visible items plus
306
+ * `overscanMin` ahead of the scroll, 'move' once the scroll runs past that
307
+ * (the window moves on with the full overscan ahead), and 'jump' when the
308
+ * visible items are nowhere in it. Without `overscanMin` every scroll moves
309
+ * the window.
310
+ */
311
+ function scrollStep(): 'hold' | 'move' | 'jump' {
312
+ const min = options.overscanMin
313
+ if (min === undefined || state.items.length === 0) return 'move'
314
+ const ahead = clamp(min, 0, Math.max(options.overscan ?? 6, 0))
315
+ const { count, viewportHeight, totalSize, visibleStart, visibleEnd } = visibleRange(options, getOffsets())
316
+ if (count === 0 || viewportHeight !== state.viewportHeight || totalSize !== state.totalSize) return 'jump'
317
+ if (visibleStart > state.endIndex || visibleEnd < state.startIndex) return 'jump'
318
+ const direction = options.scrollDirection ?? 0
319
+ const needStart = Math.max(visibleStart - (direction < 0 ? ahead : 0), 0)
320
+ const needEnd = Math.min(visibleEnd + (direction > 0 ? ahead : 0), count - 1)
321
+ return state.startIndex <= needStart && state.endIndex >= needEnd ? 'hold' : 'move'
322
+ }
323
+
261
324
  return {
262
325
  setOptions(next: Partial<VirtualizerOptions>) {
263
326
  const merged = { ...options, ...next }
@@ -278,9 +341,12 @@ export function createVirtualizer(initial: VirtualizerOptions) {
278
341
  recalc()
279
342
  },
280
343
  setScrollOffset(scrollOffset: number) {
281
- if ((options.scrollOffset ?? 0) === scrollOffset) return
282
- options = { ...options, scrollOffset }
283
- recalc()
344
+ const previous = options.scrollOffset ?? 0
345
+ if (previous === scrollOffset) return
346
+ options = { ...options, scrollOffset, scrollDirection: scrollOffset > previous ? 1 : -1 }
347
+ const step = scrollStep()
348
+ if (step === 'hold') return
349
+ recalc(step === 'move')
284
350
  },
285
351
  setViewportHeight(viewportHeight: number) {
286
352
  if (options.viewportHeight === viewportHeight) return
@@ -0,0 +1,67 @@
1
+ /**
2
+ * What marks an array as windowed data, and how to tell (windowed-data.ts has
3
+ * the full story). Kept apart from createWindowedData so code that only asks
4
+ * "is this windowed?" - the controller, the core's per-update paths - does not
5
+ * pull in the windowed row model that createWindowedData installs.
6
+ */
7
+
8
+ const WINDOWED = Symbol.for('svgrid.windowedData')
9
+
10
+ export type WindowedSource<T> = {
11
+ /** The number of rows, loaded or not. */
12
+ readonly length: number
13
+ /** The entry at `index`: a loaded row, a placeholder, or undefined. */
14
+ at(index: number): T | undefined
15
+ }
16
+
17
+ const isIndexKey = (key: string | symbol): key is string =>
18
+ typeof key === 'string' && key.length > 0 && key.length < 16 && /^(0|[1-9]\d*)$/.test(key)
19
+
20
+ /**
21
+ * An array of `length` entries read through `at(i)`: a real `Array` (a Proxy
22
+ * over an empty one) carrying the windowed mark. Each call returns a new
23
+ * array identity, which is how a source tells the grid its rows changed.
24
+ */
25
+ export function makeWindowedArray<T>(length: number, at: (index: number) => T | undefined): T[] {
26
+ const source: WindowedSource<T> = { length, at }
27
+ const target: T[] = []
28
+ return new Proxy(target, {
29
+ get(t, key, receiver) {
30
+ if (key === WINDOWED) return source
31
+ if (key === 'length') return length
32
+ if (isIndexKey(key)) {
33
+ const i = Number(key)
34
+ return i < length ? at(i) : undefined
35
+ }
36
+ return Reflect.get(t, key, receiver)
37
+ },
38
+ has(t, key) {
39
+ if (key === WINDOWED) return true
40
+ if (isIndexKey(key)) return Number(key) < length
41
+ return Reflect.has(t, key)
42
+ },
43
+ set() {
44
+ // Read-only: a write would land in the empty target and never be seen.
45
+ return false
46
+ },
47
+ getOwnPropertyDescriptor(t, key) {
48
+ if (isIndexKey(key) && Number(key) < length) {
49
+ return { value: at(Number(key)), writable: false, enumerable: true, configurable: true }
50
+ }
51
+ if (key === 'length') return { value: length, writable: true, enumerable: false, configurable: false }
52
+ return Reflect.getOwnPropertyDescriptor(t, key)
53
+ },
54
+ })
55
+ }
56
+
57
+ /** The source behind windowed data, or null for an ordinary array. */
58
+ export function windowedSourceOf<T>(data: unknown): WindowedSource<T> | null {
59
+ if (data === null || typeof data !== 'object') return null
60
+ const source = (data as Record<symbol, unknown>)[WINDOWED]
61
+ return (source as WindowedSource<T> | undefined) ?? null
62
+ }
63
+
64
+ /** True for windowed data. */
65
+ export function isWindowedData(data: unknown): boolean {
66
+ return windowedSourceOf(data) !== null
67
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Windowed data: an array whose entries are read on demand, so a server row
3
+ * model can hand the grid 100M rows without building 100M of anything.
4
+ */
5
+ import { describe, expect, it } from 'vitest'
6
+ import { createWindowedData, isWindowedData, windowedSourceOf } from './windowed-data'
7
+ import { createSvGridCore } from './core'
8
+ import { createCoreRowModel, tableFeatures } from './index'
9
+ import { createBlockCache } from './server-block-cache'
10
+
11
+ describe('createWindowedData', () => {
12
+ it('reports the length and reads entries through at()', () => {
13
+ const reads: number[] = []
14
+ const data = createWindowedData(100_000_000, (i) => {
15
+ reads.push(i)
16
+ return { id: i }
17
+ })
18
+ expect(Array.isArray(data)).toBe(true)
19
+ expect(data.length).toBe(100_000_000)
20
+ expect(data[99_999_999]).toEqual({ id: 99_999_999 })
21
+ expect(data[100_000_000]).toBeUndefined()
22
+ expect(reads).toEqual([99_999_999])
23
+ })
24
+
25
+ it('is recognised, and ordinary arrays are not', () => {
26
+ const data = createWindowedData(3, (i) => i)
27
+ expect(isWindowedData(data)).toBe(true)
28
+ expect(windowedSourceOf(data)?.length).toBe(3)
29
+ expect(isWindowedData([1, 2, 3])).toBe(false)
30
+ expect(isWindowedData(null)).toBe(false)
31
+ })
32
+
33
+ it('still iterates like an array', () => {
34
+ const data = createWindowedData(4, (i) => i * 10)
35
+ expect([...data]).toEqual([0, 10, 20, 30])
36
+ expect(data.slice(1, 3)).toEqual([10, 20])
37
+ expect(2 in data).toBe(true)
38
+ expect(9 in data).toBe(false)
39
+ })
40
+
41
+ it('refuses writes', () => {
42
+ const data = createWindowedData(2, (i) => i) as number[]
43
+ expect(() => {
44
+ 'use strict'
45
+ data[0] = 5
46
+ }).toThrow()
47
+ })
48
+ })
49
+
50
+ describe('core row model over windowed data', () => {
51
+ function grid(data: unknown) {
52
+ return createSvGridCore({
53
+ _features: tableFeatures({}),
54
+ _rowModels: { coreRowModel: createCoreRowModel() },
55
+ columns: [{ field: 'v', header: 'V' }],
56
+ data: data as any[],
57
+ getRowId: (r: any) => String(r.id),
58
+ state: {},
59
+ } as any)
60
+ }
61
+
62
+ it('builds a row only for the indices that are read', () => {
63
+ let reads = 0
64
+ const data = createWindowedData(50_000_000, (i) => {
65
+ reads += 1
66
+ return { id: i, v: i * 2 }
67
+ })
68
+ const rows = grid(data).getRowModel().rows
69
+ expect(rows.length).toBe(50_000_000)
70
+ expect(reads).toBe(0)
71
+ const row = rows[25_000_000]!
72
+ expect(row.id).toBe('25000000')
73
+ expect(row.index).toBe(25_000_000)
74
+ expect(row.getCellValueByColumnId('v')).toBe(50_000_000)
75
+ expect(reads).toBe(1)
76
+ })
77
+
78
+ it('keeps a row object while its data object is the same', () => {
79
+ const objects = [{ id: 0, v: 1 }, { id: 1, v: 2 }]
80
+ const g = grid(createWindowedData(2, (i) => objects[i]))
81
+ const first = g.getRowModel().rows[1]
82
+ expect(g.getRowModel().rows[1]).toBe(first)
83
+ })
84
+ })
85
+
86
+ describe('block cache windowedRows', () => {
87
+ it('spans the row count without materialising it', async () => {
88
+ const cache = createBlockCache<{ id: number }>({
89
+ blockSize: 100,
90
+ initialRowCount: 1,
91
+ fetch: async (start, end) => ({
92
+ rows: Array.from({ length: end - start }, (_, k) => ({ id: start + k })),
93
+ rowCount: 100_000_000,
94
+ }),
95
+ onChange: () => {},
96
+ })
97
+ cache.setViewport(0, 10)
98
+ for (let i = 0; i < 10; i += 1) await Promise.resolve()
99
+ const rows = cache.windowedRows()
100
+ expect(rows.length).toBe(100_000_000)
101
+ expect(rows[5]).toEqual({ id: 5 })
102
+ expect(cache.windowedRows()).toBe(rows) // memoised until the next change
103
+ cache.dispose()
104
+ })
105
+ })
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Windowed data: an array the grid can index without anyone having built it.
3
+ *
4
+ * A server row model knows how many rows there are and holds a few blocks of
5
+ * them. Handing the grid a dense array of `rowCount` entries (placeholders
6
+ * for everything not loaded) made every block landing cost O(rowCount): the
7
+ * array itself, a copy in the controller, and a row object per entry in the
8
+ * core. At 10M rows that was ~1 GB of heap and ~1 s per block; at 100M the
9
+ * grid never painted.
10
+ *
11
+ * `createWindowedData(length, at)` returns a real `Array` (a Proxy over one)
12
+ * whose `length` is the row count and whose entries are read through `at(i)`
13
+ * on demand. The grid recognises it (`windowedSourceOf`) and builds row
14
+ * objects only for the indices it actually reads - the rows on screen, plus
15
+ * whatever a user action touches. Iterating one still works (it is an array),
16
+ * but costs O(length), so the grid's per-update paths check for it first.
17
+ *
18
+ * Windowed data is final: the source has already sorted, filtered and grouped
19
+ * it, so the grid's own sort / filter / group stages do not run over it.
20
+ *
21
+ * The row model that reads it (windowed-row-model.ts) is installed by the
22
+ * first call here, so a grid never handed windowed data does not bundle it.
23
+ */
24
+ import { makeWindowedArray } from './windowed-brand'
25
+ import { enableWindowedRowModel } from './windowed-row-model'
26
+
27
+ export { windowedSourceOf, isWindowedData, type WindowedSource } from './windowed-brand'
28
+
29
+ /**
30
+ * An array of `length` entries read through `at(i)`. Each call returns a new
31
+ * array identity, which is how a source tells the grid its rows changed.
32
+ */
33
+ export function createWindowedData<T>(length: number, at: (index: number) => T | undefined): T[] {
34
+ enableWindowedRowModel()
35
+ return makeWindowedArray(length, at)
36
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The row model for windowed data (see windowed-data.ts).
3
+ *
4
+ * No build loop and no pipeline stages: `rows` is itself windowed. A row
5
+ * object is made the first time an index is read and kept (up to
6
+ * WINDOWED_ROW_CAP) while its data object is the same one, so scrolling back
7
+ * does not rebuild what is still loaded, and scrolling a 100M-row source end
8
+ * to end keeps a few thousand rows, not 100M.
9
+ *
10
+ * Installed into the core by the first createWindowedData() call rather than
11
+ * imported by core.ts, so a grid that is never handed windowed data does not
12
+ * bundle any of this.
13
+ */
14
+ import {
15
+ BASE_ROW_METHODS,
16
+ ROW_CELLS,
17
+ ROW_CTX,
18
+ ROW_FILTER_GEN,
19
+ ROW_FILTER_POS,
20
+ ROW_SORT_DROPPED,
21
+ ROW_SORT_KEY,
22
+ ROW_VALUES,
23
+ registerWindowedRowModel,
24
+ type BaseRowCtx,
25
+ type BaseRowState,
26
+ type Column,
27
+ type Row,
28
+ type RowData,
29
+ type RowModel,
30
+ } from './core'
31
+ import { makeWindowedArray, windowedSourceOf } from './windowed-brand'
32
+
33
+ /** The windowed-data version a row's memoised values belong to. */
34
+ const ROW_WINDOW_GEN = Symbol('svgrid.row.windowGen')
35
+ const WINDOWED_ROW_CAP = 20_000
36
+
37
+ let installed = false
38
+
39
+ export function enableWindowedRowModel(): void {
40
+ if (installed) return
41
+ installed = true
42
+ registerWindowedRowModel(<TData extends RowData>(ctx: {
43
+ rowCtxFor: (columns: Array<Column<TData>>) => BaseRowCtx<TData>
44
+ getRowId: () => ((row: TData, index: number) => string) | undefined
45
+ }) => {
46
+ // Per grid: the model for the current input, and the row objects built
47
+ // so far, by index.
48
+ let current: { input: unknown; columns: Array<Column<TData>>; model: RowModel<TData> } | null = null
49
+ const rows = new Map<number, BaseRowState<TData>>()
50
+ let generation = 0
51
+
52
+ return (data: unknown, columns: Array<Column<TData>>): RowModel<TData> | null => {
53
+ const source = windowedSourceOf<TData>(data)
54
+ if (!source) return null
55
+ if (current && current.input === data && current.columns === columns) return current.model
56
+ if (!current || current.columns !== columns) rows.clear()
57
+ const rowCtx = ctx.rowCtxFor(columns)
58
+ const getRowId = ctx.getRowId()
59
+ const gen = ++generation
60
+ const m = BASE_ROW_METHODS as unknown as BaseRowState<TData>
61
+ const rowAt = (index: number): Row<TData> | undefined => {
62
+ if (index < 0 || index >= source.length) return undefined
63
+ const original = source.at(index)
64
+ if (original === undefined) return undefined
65
+ const kept = rows.get(index) as (BaseRowState<TData> & { [ROW_WINDOW_GEN]?: number }) | undefined
66
+ if (kept && kept.original === original) {
67
+ // A new data version may follow an in-place edit of the object, as
68
+ // in the dense path: drop the memoised values once per version.
69
+ if (kept[ROW_WINDOW_GEN] !== gen) {
70
+ kept[ROW_VALUES] = null
71
+ kept[ROW_CELLS] = null
72
+ kept[ROW_WINDOW_GEN] = gen
73
+ }
74
+ return kept
75
+ }
76
+ const row = {
77
+ id: getRowId ? getRowId(original, index) : String(index),
78
+ index,
79
+ original,
80
+ depth: 0,
81
+ [ROW_CTX]: rowCtx,
82
+ [ROW_VALUES]: null,
83
+ [ROW_CELLS]: null,
84
+ [ROW_FILTER_GEN]: 0,
85
+ [ROW_FILTER_POS]: -1,
86
+ [ROW_SORT_DROPPED]: 0,
87
+ [ROW_SORT_KEY]: undefined,
88
+ [ROW_WINDOW_GEN]: gen,
89
+ getCanExpand: m.getCanExpand,
90
+ getIsExpanded: m.getIsExpanded,
91
+ toggleExpanded: m.toggleExpanded,
92
+ getIsSelected: m.getIsSelected,
93
+ toggleSelected: m.toggleSelected,
94
+ getAllCells: m.getAllCells,
95
+ getCellValueByColumnId: m.getCellValueByColumnId,
96
+ } as unknown as BaseRowState<TData>
97
+ rows.set(index, row)
98
+ if (rows.size > WINDOWED_ROW_CAP) {
99
+ // Oldest first (Map keeps insertion order); a quarter at a time.
100
+ let drop = WINDOWED_ROW_CAP / 4
101
+ for (const key of rows.keys()) {
102
+ if (drop-- <= 0) break
103
+ rows.delete(key)
104
+ }
105
+ }
106
+ return row
107
+ }
108
+ const model: RowModel<TData> = { rows: makeWindowedArray(source.length, rowAt) as Array<Row<TData>> }
109
+ current = { input: data, columns, model }
110
+ return model
111
+ }
112
+ })
113
+ }