@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.
- package/README.md +1 -1
- package/dist/GridMenus.svelte +3 -2
- package/dist/SvGrid.controller.svelte.d.ts +60 -8
- package/dist/SvGrid.controller.svelte.js +490 -114
- package/dist/SvGrid.css +186 -3
- package/dist/SvGrid.svelte +285 -164
- package/dist/SvGrid.types.d.ts +24 -7
- package/dist/cdn/{GridMenus-CcwU8I4t.js → GridMenus-B1sipj6y.js} +165 -165
- package/dist/cdn/{GridMenus-BgUS-W3o.js → GridMenus-DlAzfHLk.js} +201 -201
- package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
- package/dist/cdn/{src-kE9B98r6.js → src-BYsa66I0.js} +9747 -10178
- package/dist/cdn/{src-25JmJ_7g.js → src-pA4RroLV.js} +10709 -11140
- package/dist/cdn/svgrid.js +27 -27
- package/dist/cdn/svgrid.svelte-external.js +27 -27
- package/dist/cdn/validate-XuMe4_KR.js +80 -0
- package/dist/cell-render.js +25 -3
- package/dist/columns.js +16 -1
- package/dist/conditional-formatting.js +9 -2
- package/dist/core.d.ts +93 -0
- package/dist/core.js +313 -47
- package/dist/filtering/excel-filters.js +41 -6
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/keyboard-handlers.js +5 -1
- package/dist/selection.d.ts +2 -2
- package/dist/selection.js +46 -12
- package/dist/server-block-cache.d.ts +7 -32
- package/dist/server-block-cache.js +21 -12
- package/dist/server-data-source.js +3 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/spreadsheet.js +7 -0
- package/dist/{subscribe.js → subscribe.svelte.js} +5 -1
- package/dist/validate.js +13 -14
- package/dist/virtualization/column-virtualizer.d.ts +4 -0
- package/dist/virtualization/column-virtualizer.js +2 -0
- package/dist/virtualization/types.d.ts +20 -0
- package/dist/virtualization/virtualizer.js +90 -24
- package/dist/windowed-brand.d.ts +22 -0
- package/dist/windowed-brand.js +60 -0
- package/dist/windowed-data.d.ts +6 -0
- package/dist/windowed-data.js +34 -0
- package/dist/windowed-row-model.d.ts +1 -0
- package/dist/windowed-row-model.js +97 -0
- package/package.json +1 -1
- package/src/GridMenus.svelte +3 -2
- package/src/SvGrid.controller.svelte.ts +483 -122
- package/src/SvGrid.css +186 -3
- package/src/SvGrid.svelte +285 -164
- package/src/SvGrid.types.ts +24 -7
- package/src/cell-render.ts +24 -2
- package/src/columns.ts +16 -1
- package/src/conditional-formatting.ts +6 -4
- package/src/core.sort.test.ts +61 -0
- package/src/core.ts +309 -49
- package/src/counting-cell.test.svelte +11 -0
- package/src/filtering/excel-filters.ts +39 -6
- package/src/headless.reactivity.svelte.test.ts +89 -0
- package/src/headless.ts +1 -1
- package/src/index.ts +7 -1
- package/src/keyboard-handlers.ts +5 -1
- package/src/new-features.test.ts +24 -0
- package/src/selection.test.ts +51 -0
- package/src/selection.ts +47 -14
- package/src/server-block-cache.ts +27 -10
- package/src/server-data-source.ts +3 -1
- package/src/server.ts +6 -0
- package/src/spreadsheet.ts +6 -0
- package/src/{subscribe.test.ts → subscribe.svelte.test.ts} +1 -1
- package/src/{subscribe.ts → subscribe.svelte.ts} +5 -1
- package/src/svgrid.behavior.test.ts +58 -0
- package/src/svgrid.column-cell-recycling.test.ts +120 -0
- package/src/svgrid.interaction.test.ts +4 -1
- package/src/svgrid.new-features.wrapper.test.ts +5 -4
- package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
- package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
- package/src/validate.test.ts +20 -4
- package/src/validate.ts +13 -16
- package/src/virtualization/column-virtualizer.ts +6 -0
- package/src/virtualization/types.ts +20 -0
- package/src/virtualization/virtualizer.test.ts +152 -0
- package/src/virtualization/virtualizer.ts +93 -27
- package/src/windowed-brand.ts +67 -0
- package/src/windowed-data.test.ts +105 -0
- package/src/windowed-data.ts +36 -0
- package/src/windowed-row-model.ts +113 -0
- package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
- package/dist/cdn/validate-AZoD8BoR.js +0 -80
- /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
|
-
|
|
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
|
-
)
|
|
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
|
|
177
|
+
const uniformSize = Math.max(
|
|
186
178
|
typeof options.estimateSize === 'number' ? options.estimateSize : 1,
|
|
187
179
|
1,
|
|
188
180
|
)
|
|
189
|
-
const totalSize = count *
|
|
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 /
|
|
194
|
-
const visibleCount = Math.ceil(viewportHeight /
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
}
|