@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
@@ -7,6 +7,10 @@ export function createColumnVirtualizer(input: {
7
7
  viewportWidth: number
8
8
  scrollOffset?: number
9
9
  overscan?: number
10
+ /** Columns kept behind the scroll direction; defaults to `overscan`. */
11
+ overscanBehind?: number
12
+ /** Fewest columns left ahead of a scroll before the window moves. */
13
+ overscanMin?: number
10
14
  /** Either a uniform size (number) or a per-column size function. */
11
15
  estimateSize?: number | ColumnSizeEstimator
12
16
  }) {
@@ -16,6 +20,8 @@ export function createColumnVirtualizer(input: {
16
20
  viewportHeight: input.viewportWidth,
17
21
  scrollOffset: input.scrollOffset ?? 0,
18
22
  overscan: input.overscan ?? 4,
23
+ overscanBehind: input.overscanBehind,
24
+ overscanMin: input.overscanMin,
19
25
  })
20
26
 
21
27
  return {
@@ -16,8 +16,28 @@ export type VirtualizerOptions = {
16
16
  */
17
17
  estimateSize: number | ((index: number) => number)
18
18
  overscan?: number
19
+ /**
20
+ * Items kept on the side the scroll is moving away from (`overscan` is kept
21
+ * ahead of it). Defaults to `overscan`, a symmetric window. Before the
22
+ * first scroll there is no direction and both sides get `overscan`.
23
+ */
24
+ overscanBehind?: number
25
+ /**
26
+ * Keep the rendered window while a scroll leaves at least this many items
27
+ * rendered ahead of it, and move the window only when the scroll runs past
28
+ * that: the window is then rebuilt with the full `overscan` ahead, so it
29
+ * moves `overscan - overscanMin + 1` items at a time instead of one. Each
30
+ * move costs about the same whether one item enters or several, so fewer,
31
+ * larger moves are less work, and the scroll frames in between change
32
+ * nothing. A window built at rest, or after a jump to items outside the
33
+ * rendered window, carries `overscanMin` instead of `overscan`. Undefined
34
+ * moves the window on every item boundary, always with `overscan`.
35
+ */
36
+ overscanMin?: number
19
37
  viewportHeight: number
20
38
  scrollOffset?: number
39
+ /** Internal: the sign of the last scroll movement (-1, 0, 1). */
40
+ scrollDirection?: number
21
41
  }
22
42
 
23
43
  export type VirtualizerState = {
@@ -89,3 +89,155 @@ describe('virtualizer', () => {
89
89
  expect(calls).toBe(afterBuild)
90
90
  })
91
91
  })
92
+
93
+ describe('virtualizer: overscanBehind', () => {
94
+ // 1,000 items of 10px in a 100px viewport: items 0-9 visible at offset 0.
95
+ const make = () =>
96
+ createVirtualizer({ count: 1000, estimateSize: 10, viewportHeight: 100, overscan: 3, overscanBehind: 1 })
97
+
98
+ it('keeps the full overscan on both sides before any scroll', () => {
99
+ // Created at offset 500: no movement yet, so no direction.
100
+ const fresh = createVirtualizer({ count: 1000, estimateSize: 10, viewportHeight: 100, overscan: 3, overscanBehind: 1, scrollOffset: 500 })
101
+ const items = fresh.getVirtualItems()
102
+ expect(items[0]!.index).toBe(47)
103
+ expect(items.at(-1)!.index).toBe(50 + 10 + 3)
104
+ })
105
+
106
+ it('puts the overscan ahead of a forward scroll and one item behind it', () => {
107
+ const v = make()
108
+ v.setScrollOffset(500)
109
+ const items = v.getVirtualItems()
110
+ expect(items[0]!.index).toBe(50 - 1)
111
+ expect(items.at(-1)!.index).toBe(60 + 3)
112
+ })
113
+
114
+ it('flips when the scroll turns around', () => {
115
+ const v = make()
116
+ v.setScrollOffset(500)
117
+ v.setScrollOffset(400)
118
+ const items = v.getVirtualItems()
119
+ expect(items[0]!.index).toBe(40 - 3)
120
+ expect(items.at(-1)!.index).toBe(50 + 1)
121
+ })
122
+
123
+ it('defaults to a symmetric window', () => {
124
+ const v = createVirtualizer({ count: 1000, estimateSize: 10, viewportHeight: 100, overscan: 3 })
125
+ v.setScrollOffset(500)
126
+ const items = v.getVirtualItems()
127
+ expect(items[0]!.index).toBe(47)
128
+ expect(items.at(-1)!.index).toBe(63)
129
+ })
130
+ })
131
+
132
+ describe('virtualizer: overscanMin', () => {
133
+ // 1,000 items of 10px in a 100px viewport; at offset o the visible items
134
+ // are o/10 .. o/10 + 10.
135
+ const make = (estimateSize: number | ((i: number) => number) = 10) =>
136
+ createVirtualizer({ count: 1000, estimateSize, viewportHeight: 100, overscan: 3, overscanBehind: 1, overscanMin: 1 })
137
+
138
+ const range = (v: ReturnType<typeof make>) => {
139
+ const items = v.getVirtualItems()
140
+ return [items[0]!.index, items.at(-1)!.index]
141
+ }
142
+
143
+ it('renders overscanMin around the visible items at rest and after a jump', () => {
144
+ // At rest: one item on each side of visible 0..10.
145
+ const v = make()
146
+ expect(range(v)).toEqual([0, 11])
147
+ // Visible 50..60 shares nothing with the window: a jump, so one item
148
+ // ahead and one behind, as a thumb drag lands.
149
+ v.setScrollOffset(500)
150
+ expect(range(v)).toEqual([49, 61])
151
+ const fresh = createVirtualizer({ count: 1000, estimateSize: 10, viewportHeight: 100, overscan: 3, overscanBehind: 1, overscanMin: 1, scrollOffset: 500 })
152
+ expect(range(fresh)).toEqual([49, 61])
153
+ })
154
+
155
+ it('keeps the window while one item is left ahead, then moves it three at a time', () => {
156
+ const v = make()
157
+ let emits = 0
158
+ v.subscribe(() => (emits += 1))
159
+ // Visible end 11 leaves nothing ahead of [0, 11]: the window moves on
160
+ // with the full overscan ahead.
161
+ v.setScrollOffset(10)
162
+ const moved = v.getVirtualItems()
163
+ expect(range(v)).toEqual([0, 14])
164
+ expect(emits).toBe(1)
165
+
166
+ // Visible ends 12 and 13: item 14 is still rendered ahead of them.
167
+ v.setScrollOffset(20)
168
+ v.setScrollOffset(30)
169
+ expect(v.getVirtualItems()).toBe(moved)
170
+ expect(emits).toBe(1)
171
+
172
+ // Visible end 14 leaves nothing ahead: the window moves by three.
173
+ v.setScrollOffset(40)
174
+ expect(range(v)).toEqual([3, 17])
175
+ expect(emits).toBe(2)
176
+ v.setScrollOffset(50)
177
+ v.setScrollOffset(60)
178
+ expect(emits).toBe(2)
179
+ v.setScrollOffset(70)
180
+ expect(range(v)).toEqual([6, 20])
181
+ expect(emits).toBe(3)
182
+ })
183
+
184
+ it('works the same with per-item sizes', () => {
185
+ // The per-item path counts visible 0..9 at offset 0, not 0..10: an item
186
+ // starting exactly at the viewport's end is not in it.
187
+ const v = make(() => 10)
188
+ expect(range(v)).toEqual([0, 10])
189
+ v.setScrollOffset(10)
190
+ const moved = v.getVirtualItems()
191
+ expect(range(v)).toEqual([0, 13])
192
+ v.setScrollOffset(30)
193
+ expect(v.getVirtualItems()).toBe(moved)
194
+ v.setScrollOffset(40)
195
+ expect(range(v)).toEqual([3, 16])
196
+ })
197
+
198
+ it('keeps the window through a turn while it still covers the way back', () => {
199
+ const v = make()
200
+ v.setScrollOffset(500)
201
+ v.setScrollOffset(510)
202
+ const moved = v.getVirtualItems()
203
+ expect(range(v)).toEqual([50, 64])
204
+ v.setScrollOffset(530)
205
+ // Back up to visible 52..62 and 51..61: items 51 and 50 are one ahead.
206
+ v.setScrollOffset(520)
207
+ v.setScrollOffset(510)
208
+ expect(v.getVirtualItems()).toBe(moved)
209
+ // Visible start 50 with item 49 missing: the window moves with the full
210
+ // overscan before it and one item after.
211
+ v.setScrollOffset(500)
212
+ expect(range(v)).toEqual([47, 61])
213
+ })
214
+
215
+ it('always renders every visible item', () => {
216
+ const v = make((i) => 5 + (i % 7) * 3)
217
+ let seed = 7
218
+ const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647)
219
+ let offset = 0
220
+ for (let step = 0; step < 2000; step += 1) {
221
+ // Mostly small steps either way, now and then a jump.
222
+ offset = random() < 0.05 ? Math.floor(random() * 12_000) : Math.max(0, offset + Math.floor((random() - 0.4) * 60))
223
+ v.setScrollOffset(offset)
224
+ const state = v.getState()
225
+ const items = v.getVirtualItems()
226
+ const top = Math.min(offset, Math.max(state.totalSize - 100, 0))
227
+ const visible = items.filter((it) => it.end > top && it.start < top + 100)
228
+ expect(visible.length).toBeGreaterThan(0)
229
+ expect(items[0]!.start).toBeLessThanOrEqual(top)
230
+ expect(items.at(-1)!.end).toBeGreaterThanOrEqual(Math.min(top + 100, state.totalSize))
231
+ }
232
+ })
233
+
234
+ it('re-windows at once when the viewport or the sizes change', () => {
235
+ const v = make()
236
+ v.setScrollOffset(500)
237
+ v.setScrollOffset(510)
238
+ v.setViewportHeight(200)
239
+ expect(range(v)).toEqual([50, 51 + 20 + 1])
240
+ v.setOptions({ estimateSize: 20 })
241
+ expect(v.getVirtualItems()[0]!.start).toBe(v.getVirtualItems()[0]!.index * 20)
242
+ })
243
+ })
@@ -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
+ }