@svgrid/grid 3.0.4 → 4.0.0
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 +3 -3
- package/dist/SvGrid.controller.svelte.d.ts +12 -2
- package/dist/SvGrid.controller.svelte.js +191 -8
- package/dist/SvGrid.css +73 -0
- package/dist/SvGrid.svelte +82 -3
- package/dist/SvGrid.types.d.ts +105 -3
- package/dist/build-api.js +11 -6
- package/dist/cdn/{GridMenus-7kbpnnBW.js → GridMenus-CLI93jke.js} +26 -26
- package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-CwooL3QA.js} +26 -26
- package/dist/cdn/server-block-cache-CGoWz-87.js +237 -0
- package/dist/cdn/{src-BKhZ6eXd.js → src-B6HXdfHY.js} +4099 -4140
- package/dist/cdn/{src-V1uu8iE9.js → src-C6Iowdvs.js} +4052 -4093
- package/dist/cdn/svgrid.js +27 -26
- package/dist/cdn/svgrid.svelte-external.js +27 -26
- package/dist/cdn/validate-CchXzyrX.js +76 -0
- package/dist/core.js +36 -13
- package/dist/editing.js +10 -0
- package/dist/grid-icons.d.ts +2 -2
- package/dist/grid-icons.js +2 -2
- package/dist/grid-messages.d.ts +2 -0
- package/dist/grid-messages.js +2 -0
- package/dist/index.d.ts +3 -4
- package/dist/index.js +2 -3
- package/dist/row-model.d.ts +133 -0
- package/dist/row-model.js +16 -0
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +30 -2
- package/dist/server-block-cache.d.ts +214 -0
- package/dist/server-block-cache.js +531 -0
- package/dist/server-data-source.d.ts +240 -6
- package/dist/server-data-source.js +220 -20
- package/dist/server.d.ts +13 -0
- package/dist/server.js +13 -0
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +8 -3
- package/package.json +6 -1
- package/src/SvGrid.controller.svelte.ts +190 -8
- package/src/SvGrid.css +73 -0
- package/src/SvGrid.svelte +82 -3
- package/src/SvGrid.types.ts +105 -3
- package/src/build-api.ts +11 -4
- package/src/core.rowmodel-cache.test.ts +51 -0
- package/src/core.ts +33 -12
- package/src/editing.ts +8 -0
- package/src/grid-icons.ts +2 -2
- package/src/grid-messages.ts +5 -0
- package/src/icon-seam.test.ts +2 -4
- package/src/index.ts +19 -10
- package/src/row-model.ts +146 -0
- package/src/selection.test.ts +3 -0
- package/src/selection.ts +32 -2
- package/src/server-block-cache.test.ts +645 -0
- package/src/server-block-cache.ts +677 -0
- package/src/server-data-source.infinite.test.ts +343 -0
- package/src/server-data-source.ts +450 -27
- package/src/server.ts +47 -0
- package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
- package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
- package/src/validate.test.ts +10 -0
- package/src/validate.ts +10 -3
- package/dist/SvGroupCell.svelte +0 -141
- package/dist/SvGroupCell.svelte.d.ts +0 -49
- package/dist/SvRowGroupPanel.svelte +0 -186
- package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
- package/dist/cdn/validate-_CDJzgIo.js +0 -75
- package/dist/server-group-model.d.ts +0 -98
- package/dist/server-group-model.js +0 -263
- package/src/SvGroupCell.svelte +0 -141
- package/src/SvRowGroupPanel.svelte +0 -186
- package/src/server-group-model.test.ts +0 -294
- package/src/server-group-model.ts +0 -370
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createBlockCache, rowPlaceholderState } from './server-block-cache'
|
|
3
|
+
|
|
4
|
+
type Row = { id: number }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A backend under the test's control: `pending` holds every open request so a
|
|
8
|
+
* test can decide the order and the timing, which is the whole point when the
|
|
9
|
+
* thing under test is about concurrency, debouncing and abandonment.
|
|
10
|
+
*/
|
|
11
|
+
function scriptedFetch(opts: { total?: number | null; failBlocks?: Set<number> } = {}) {
|
|
12
|
+
const total = opts.total === undefined ? 1000 : opts.total
|
|
13
|
+
const failBlocks = opts.failBlocks ?? new Set<number>()
|
|
14
|
+
const calls: Array<{ startRow: number; endRow: number; aborted: () => boolean }> = []
|
|
15
|
+
const pending: Array<{ start: number; resolve: () => void; reject: () => void }> = []
|
|
16
|
+
|
|
17
|
+
const fetch = (startRow: number, endRow: number, signal: AbortSignal) => {
|
|
18
|
+
calls.push({ startRow, endRow, aborted: () => signal.aborted })
|
|
19
|
+
return new Promise<{ rows: Row[]; rowCount?: number }>((resolve, reject) => {
|
|
20
|
+
pending.push({
|
|
21
|
+
start: startRow,
|
|
22
|
+
resolve: () => {
|
|
23
|
+
const end = total == null ? endRow : Math.min(endRow, total)
|
|
24
|
+
const rows: Row[] = []
|
|
25
|
+
for (let i = startRow; i < end; i += 1) rows.push({ id: i })
|
|
26
|
+
resolve(total == null ? { rows } : { rows, rowCount: total })
|
|
27
|
+
},
|
|
28
|
+
reject: () => reject(new Error('boom')),
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
fetch,
|
|
35
|
+
calls,
|
|
36
|
+
/** Mutable, so a test can fix the backend and retry. */
|
|
37
|
+
failBlocks,
|
|
38
|
+
/** Settle every open request, in order, and let the microtask queue drain. */
|
|
39
|
+
async flush() {
|
|
40
|
+
while (pending.length) {
|
|
41
|
+
const next = pending.shift()!
|
|
42
|
+
if (failBlocks.has(Math.floor(next.start / 100))) next.reject()
|
|
43
|
+
else next.resolve()
|
|
44
|
+
await settle()
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
openCount: () => pending.length,
|
|
48
|
+
startsRequested: () => calls.map((c) => c.startRow),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const settle = async () => {
|
|
53
|
+
for (let i = 0; i < 12; i += 1) await Promise.resolve()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Emit synchronously so assertions read straight after the action. */
|
|
57
|
+
const sync = (fn: () => void) => fn()
|
|
58
|
+
|
|
59
|
+
describe('createBlockCache', () => {
|
|
60
|
+
it('fetches the blocks the viewport covers and nothing else', async () => {
|
|
61
|
+
const server = scriptedFetch()
|
|
62
|
+
const cache = createBlockCache<Row>({
|
|
63
|
+
blockSize: 100,
|
|
64
|
+
fetch: server.fetch,
|
|
65
|
+
onChange: () => {},
|
|
66
|
+
schedule: sync,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
cache.setViewport(0, 20)
|
|
70
|
+
await server.flush()
|
|
71
|
+
expect(server.startsRequested()).toEqual([0])
|
|
72
|
+
|
|
73
|
+
// Scrolling within the same block asks for nothing new.
|
|
74
|
+
cache.setViewport(30, 60)
|
|
75
|
+
await server.flush()
|
|
76
|
+
expect(server.startsRequested()).toEqual([0])
|
|
77
|
+
|
|
78
|
+
// A viewport straddling a boundary pulls both blocks.
|
|
79
|
+
cache.setViewport(90, 110)
|
|
80
|
+
await server.flush()
|
|
81
|
+
expect(server.startsRequested()).toEqual([0, 100])
|
|
82
|
+
cache.dispose()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('renders a placeholder for every row it has not loaded', async () => {
|
|
86
|
+
const server = scriptedFetch()
|
|
87
|
+
const cache = createBlockCache<Row>({
|
|
88
|
+
blockSize: 100,
|
|
89
|
+
fetch: server.fetch,
|
|
90
|
+
onChange: () => {},
|
|
91
|
+
schedule: sync,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
cache.setViewport(0, 20)
|
|
95
|
+
await server.flush()
|
|
96
|
+
|
|
97
|
+
const rows = cache.rows()
|
|
98
|
+
expect(rows).toHaveLength(1000)
|
|
99
|
+
expect(rows[0]).toEqual({ id: 0 })
|
|
100
|
+
expect(rowPlaceholderState(rows[0])).toBeNull()
|
|
101
|
+
expect(rowPlaceholderState(rows[500])).toBe('loading')
|
|
102
|
+
// The mark is a symbol, so a row with a field of that NAME is still data.
|
|
103
|
+
expect(rowPlaceholderState({ __svPlaceholder: 'loading' })).toBeNull()
|
|
104
|
+
// And it survives whatever wrapped the row on the way here - a Svelte
|
|
105
|
+
// $state proxy being the case that matters in practice.
|
|
106
|
+
const proxied = new Proxy(rows[500] as object, {})
|
|
107
|
+
expect(rowPlaceholderState(proxied)).toBe('loading')
|
|
108
|
+
cache.dispose()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('deduplicates a block already in flight', async () => {
|
|
112
|
+
const server = scriptedFetch()
|
|
113
|
+
const cache = createBlockCache<Row>({
|
|
114
|
+
blockSize: 100,
|
|
115
|
+
fetch: server.fetch,
|
|
116
|
+
onChange: () => {},
|
|
117
|
+
schedule: sync,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
cache.setViewport(0, 20)
|
|
121
|
+
cache.setViewport(10, 30)
|
|
122
|
+
cache.setViewport(40, 60)
|
|
123
|
+
expect(server.startsRequested()).toEqual([0])
|
|
124
|
+
await server.flush()
|
|
125
|
+
expect(server.startsRequested()).toEqual([0])
|
|
126
|
+
cache.dispose()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('holds requests over the concurrency cap until a slot frees', async () => {
|
|
130
|
+
const server = scriptedFetch()
|
|
131
|
+
const cache = createBlockCache<Row>({
|
|
132
|
+
blockSize: 100,
|
|
133
|
+
maxConcurrentRequests: 2,
|
|
134
|
+
fetch: server.fetch,
|
|
135
|
+
onChange: () => {},
|
|
136
|
+
schedule: sync,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
cache.setViewport(0, 450) // five blocks wanted at once
|
|
140
|
+
expect(server.openCount()).toBe(2)
|
|
141
|
+
expect(server.startsRequested()).toEqual([0, 100])
|
|
142
|
+
|
|
143
|
+
await server.flush()
|
|
144
|
+
expect(server.startsRequested()).toEqual([0, 100, 200, 300, 400])
|
|
145
|
+
cache.dispose()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('waits for the scroll to settle when a debounce is set', async () => {
|
|
149
|
+
vi.useFakeTimers()
|
|
150
|
+
const server = scriptedFetch()
|
|
151
|
+
const cache = createBlockCache<Row>({
|
|
152
|
+
blockSize: 100,
|
|
153
|
+
blockLoadDebounceMs: 50,
|
|
154
|
+
fetch: server.fetch,
|
|
155
|
+
onChange: () => {},
|
|
156
|
+
schedule: sync,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// A drag of the scrollbar across the whole table.
|
|
160
|
+
cache.setViewport(0, 20)
|
|
161
|
+
cache.setViewport(300, 320)
|
|
162
|
+
cache.setViewport(700, 720)
|
|
163
|
+
expect(server.startsRequested()).toEqual([])
|
|
164
|
+
|
|
165
|
+
vi.advanceTimersByTime(50)
|
|
166
|
+
expect(server.startsRequested()).toEqual([700]) // only where it stopped
|
|
167
|
+
cache.dispose()
|
|
168
|
+
vi.useRealTimers()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('evicts the least recently seen block once over the limit, sparing the viewport', async () => {
|
|
172
|
+
const server = scriptedFetch()
|
|
173
|
+
const cache = createBlockCache<Row>({
|
|
174
|
+
blockSize: 100,
|
|
175
|
+
maxBlocksInCache: 2,
|
|
176
|
+
fetch: server.fetch,
|
|
177
|
+
onChange: () => {},
|
|
178
|
+
schedule: sync,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
cache.setViewport(0, 50)
|
|
182
|
+
await server.flush()
|
|
183
|
+
cache.setViewport(100, 150)
|
|
184
|
+
await server.flush()
|
|
185
|
+
cache.setViewport(200, 250)
|
|
186
|
+
await server.flush()
|
|
187
|
+
|
|
188
|
+
const held = cache.getCacheState().map((b) => b.blockIndex)
|
|
189
|
+
expect(held).toEqual([1, 2]) // block 0, longest unseen, is gone
|
|
190
|
+
expect(rowPlaceholderState(cache.getRow(0))).toBe('loading')
|
|
191
|
+
expect(cache.getRow(200)).toEqual({ id: 200 })
|
|
192
|
+
cache.dispose()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('re-fetches an evicted block when the user scrolls back', async () => {
|
|
196
|
+
const server = scriptedFetch()
|
|
197
|
+
const cache = createBlockCache<Row>({
|
|
198
|
+
blockSize: 100,
|
|
199
|
+
maxBlocksInCache: 1,
|
|
200
|
+
fetch: server.fetch,
|
|
201
|
+
onChange: () => {},
|
|
202
|
+
schedule: sync,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
cache.setViewport(0, 50)
|
|
206
|
+
await server.flush()
|
|
207
|
+
cache.setViewport(500, 550)
|
|
208
|
+
await server.flush()
|
|
209
|
+
cache.setViewport(0, 50)
|
|
210
|
+
await server.flush()
|
|
211
|
+
|
|
212
|
+
expect(server.startsRequested()).toEqual([0, 500, 0])
|
|
213
|
+
expect(cache.getRow(0)).toEqual({ id: 0 })
|
|
214
|
+
cache.dispose()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('grows a block at a time while the total is unknown, then stops at a short block', async () => {
|
|
218
|
+
// A backend that will not count: `rowCount` is never sent, and the data
|
|
219
|
+
// runs out at 250 rows.
|
|
220
|
+
const pending: Array<() => void> = []
|
|
221
|
+
const fetch = (startRow: number, endRow: number) =>
|
|
222
|
+
new Promise<{ rows: Row[] }>((resolve) => {
|
|
223
|
+
pending.push(() => {
|
|
224
|
+
const rows: Row[] = []
|
|
225
|
+
for (let i = startRow; i < Math.min(endRow, 250); i += 1) rows.push({ id: i })
|
|
226
|
+
resolve({ rows })
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
const flush = async () => {
|
|
230
|
+
while (pending.length) {
|
|
231
|
+
pending.shift()!()
|
|
232
|
+
await settle()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const cache = createBlockCache<Row>({
|
|
237
|
+
blockSize: 100,
|
|
238
|
+
initialRowCount: 1,
|
|
239
|
+
overflowRows: 1,
|
|
240
|
+
fetch,
|
|
241
|
+
onChange: () => {},
|
|
242
|
+
schedule: sync,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
expect(cache.rows()).toHaveLength(1) // the initial guess
|
|
246
|
+
expect(cache.lastRowKnown()).toBe(false)
|
|
247
|
+
|
|
248
|
+
cache.setViewport(0, 20)
|
|
249
|
+
await flush()
|
|
250
|
+
// A full block came back, so there is more: 100 loaded + 1 to scroll into.
|
|
251
|
+
expect(cache.rows()).toHaveLength(101)
|
|
252
|
+
expect(cache.lastRowKnown()).toBe(false)
|
|
253
|
+
|
|
254
|
+
cache.setViewport(100, 120)
|
|
255
|
+
await flush()
|
|
256
|
+
expect(cache.rows()).toHaveLength(201)
|
|
257
|
+
|
|
258
|
+
cache.setViewport(200, 220)
|
|
259
|
+
await flush()
|
|
260
|
+
// Block 2 came back short (50 rows), which is where the data ends.
|
|
261
|
+
expect(cache.lastRowKnown()).toBe(true)
|
|
262
|
+
expect(cache.rowCount()).toBe(250)
|
|
263
|
+
expect(cache.rows()).toHaveLength(250)
|
|
264
|
+
cache.dispose()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('marks a failed block and recovers on retry', async () => {
|
|
268
|
+
const server = scriptedFetch({ failBlocks: new Set([1]) })
|
|
269
|
+
const seen: Array<number[]> = []
|
|
270
|
+
const cache = createBlockCache<Row>({
|
|
271
|
+
blockSize: 100,
|
|
272
|
+
fetch: server.fetch,
|
|
273
|
+
onChange: (s) => seen.push(s.failedBlocks),
|
|
274
|
+
schedule: sync,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
cache.setViewport(100, 150)
|
|
278
|
+
await server.flush()
|
|
279
|
+
|
|
280
|
+
expect(cache.getCacheState()[0]!.status).toBe('failed')
|
|
281
|
+
expect(rowPlaceholderState(cache.getRow(120))).toBe('failed')
|
|
282
|
+
expect(seen.at(-1)).toEqual([1])
|
|
283
|
+
|
|
284
|
+
// A failed block is not re-requested just because the viewport moved over
|
|
285
|
+
// it again - a broken backend should not be hammered once per scroll.
|
|
286
|
+
cache.setViewport(100, 150)
|
|
287
|
+
expect(server.startsRequested()).toEqual([100])
|
|
288
|
+
|
|
289
|
+
server.failBlocks.delete(1)
|
|
290
|
+
cache.retryFailed()
|
|
291
|
+
await server.flush()
|
|
292
|
+
expect(cache.getRow(120)).toEqual({ id: 120 })
|
|
293
|
+
expect(cache.getCacheState()[0]!.status).toBe('loaded')
|
|
294
|
+
cache.dispose()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('keeps the first block at its skeleton length when it fails with no count', async () => {
|
|
298
|
+
// Nothing is known about the list yet, so the failed rows are the few
|
|
299
|
+
// the level showed while loading, not a hundred and one of them.
|
|
300
|
+
const server = scriptedFetch({ total: null, failBlocks: new Set([0]) })
|
|
301
|
+
const cache = createBlockCache<Row>({
|
|
302
|
+
blockSize: 100,
|
|
303
|
+
initialRowCount: 6,
|
|
304
|
+
fetch: server.fetch,
|
|
305
|
+
onChange: () => {},
|
|
306
|
+
schedule: sync,
|
|
307
|
+
})
|
|
308
|
+
cache.setViewport(0, 40)
|
|
309
|
+
await server.flush()
|
|
310
|
+
expect(cache.getCacheState()[0]!.status).toBe('failed')
|
|
311
|
+
expect(cache.rows()).toHaveLength(6)
|
|
312
|
+
expect(cache.rows().every((r) => rowPlaceholderState(r) === 'failed')).toBe(true)
|
|
313
|
+
|
|
314
|
+
// A block the user scrolled to keeps its slots when it fails.
|
|
315
|
+
server.failBlocks.delete(0)
|
|
316
|
+
server.failBlocks.add(2)
|
|
317
|
+
cache.retryFailed()
|
|
318
|
+
await server.flush()
|
|
319
|
+
cache.setViewport(200, 240)
|
|
320
|
+
await server.flush()
|
|
321
|
+
expect(cache.getCacheState().find((b) => b.blockIndex === 2)!.status).toBe('failed')
|
|
322
|
+
expect(cache.rows().length).toBeGreaterThanOrEqual(300)
|
|
323
|
+
cache.dispose()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('refresh re-requests the same blocks and purge starts over', async () => {
|
|
327
|
+
const server = scriptedFetch()
|
|
328
|
+
const cache = createBlockCache<Row>({
|
|
329
|
+
blockSize: 100,
|
|
330
|
+
fetch: server.fetch,
|
|
331
|
+
onChange: () => {},
|
|
332
|
+
schedule: sync,
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
cache.setViewport(0, 50)
|
|
336
|
+
await server.flush()
|
|
337
|
+
expect(cache.rowCount()).toBe(1000)
|
|
338
|
+
|
|
339
|
+
cache.refresh()
|
|
340
|
+
await server.flush()
|
|
341
|
+
expect(server.startsRequested()).toEqual([0, 0])
|
|
342
|
+
expect(cache.rowCount()).toBe(1000) // the count survived
|
|
343
|
+
|
|
344
|
+
cache.purge()
|
|
345
|
+
expect(cache.rowCount()).toBeNull() // the count did not
|
|
346
|
+
await server.flush()
|
|
347
|
+
expect(server.startsRequested()).toEqual([0, 0, 0])
|
|
348
|
+
cache.dispose()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('aborts the requests it abandons', async () => {
|
|
352
|
+
const server = scriptedFetch()
|
|
353
|
+
const cache = createBlockCache<Row>({
|
|
354
|
+
blockSize: 100,
|
|
355
|
+
fetch: server.fetch,
|
|
356
|
+
onChange: () => {},
|
|
357
|
+
schedule: sync,
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
cache.setViewport(0, 50)
|
|
361
|
+
expect(server.calls[0]!.aborted()).toBe(false)
|
|
362
|
+
cache.purge()
|
|
363
|
+
expect(server.calls[0]!.aborted()).toBe(true)
|
|
364
|
+
|
|
365
|
+
cache.dispose()
|
|
366
|
+
for (const call of server.calls) expect(call.aborted()).toBe(true)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('ignores a response that lands after dispose', async () => {
|
|
370
|
+
const server = scriptedFetch()
|
|
371
|
+
let emits = 0
|
|
372
|
+
const cache = createBlockCache<Row>({
|
|
373
|
+
blockSize: 100,
|
|
374
|
+
fetch: server.fetch,
|
|
375
|
+
onChange: () => (emits += 1),
|
|
376
|
+
schedule: sync,
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
cache.setViewport(0, 50)
|
|
380
|
+
const before = emits
|
|
381
|
+
cache.dispose()
|
|
382
|
+
await server.flush()
|
|
383
|
+
expect(emits).toBe(before)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('patches one loaded row and leaves the rest alone', async () => {
|
|
387
|
+
const server = scriptedFetch()
|
|
388
|
+
const cache = createBlockCache<Row>({
|
|
389
|
+
blockSize: 100,
|
|
390
|
+
fetch: server.fetch,
|
|
391
|
+
onChange: () => {},
|
|
392
|
+
schedule: sync,
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
cache.setViewport(0, 50)
|
|
396
|
+
await server.flush()
|
|
397
|
+
|
|
398
|
+
expect(cache.patch(5, { id: 999 })).toBe(true)
|
|
399
|
+
expect(cache.getRow(5)).toEqual({ id: 999 })
|
|
400
|
+
expect(cache.getRow(6)).toEqual({ id: 6 })
|
|
401
|
+
// A row in an unloaded block cannot be patched, and says so.
|
|
402
|
+
expect(cache.patch(500, { id: -1 })).toBe(false)
|
|
403
|
+
cache.dispose()
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('shifts rows across block boundaries on insert and remove', async () => {
|
|
407
|
+
const server = scriptedFetch({ total: 250 })
|
|
408
|
+
const cache = createBlockCache<Row>({
|
|
409
|
+
blockSize: 100,
|
|
410
|
+
fetch: server.fetch,
|
|
411
|
+
onChange: () => {},
|
|
412
|
+
schedule: sync,
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
cache.setViewport(0, 250)
|
|
416
|
+
await server.flush()
|
|
417
|
+
expect(cache.rowCount()).toBe(250)
|
|
418
|
+
|
|
419
|
+
cache.insert(0, [{ id: -1 }])
|
|
420
|
+
expect(cache.rowCount()).toBe(251)
|
|
421
|
+
expect(cache.getRow(0)).toEqual({ id: -1 })
|
|
422
|
+
// The row that was at 99 has moved into the next block.
|
|
423
|
+
expect(cache.getRow(100)).toEqual({ id: 99 })
|
|
424
|
+
|
|
425
|
+
cache.remove(0, 1)
|
|
426
|
+
expect(cache.rowCount()).toBe(250)
|
|
427
|
+
expect(cache.getRow(0)).toEqual({ id: 0 })
|
|
428
|
+
expect(cache.getRow(100)).toEqual({ id: 100 })
|
|
429
|
+
cache.dispose()
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('coalesces a burst of arrivals into one emit by default', async () => {
|
|
433
|
+
const server = scriptedFetch()
|
|
434
|
+
let emits = 0
|
|
435
|
+
const cache = createBlockCache<Row>({
|
|
436
|
+
blockSize: 100,
|
|
437
|
+
maxConcurrentRequests: 4,
|
|
438
|
+
fetch: server.fetch,
|
|
439
|
+
onChange: () => (emits += 1),
|
|
440
|
+
// No `schedule` override: the default microtask coalescing applies.
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
cache.setViewport(0, 350)
|
|
444
|
+
await server.flush()
|
|
445
|
+
// Four blocks were requested and landed; without coalescing that is eight
|
|
446
|
+
// emits (one per start, one per finish) and eight row-array rebuilds.
|
|
447
|
+
expect(emits).toBeLessThan(8)
|
|
448
|
+
expect(emits).toBeGreaterThan(0)
|
|
449
|
+
cache.dispose()
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('lets a separate count endpoint set the total', () => {
|
|
453
|
+
const cache = createBlockCache<Row>({
|
|
454
|
+
blockSize: 100,
|
|
455
|
+
fetch: async () => ({ rows: [] }),
|
|
456
|
+
onChange: () => {},
|
|
457
|
+
schedule: sync,
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
expect(cache.lastRowKnown()).toBe(false)
|
|
461
|
+
cache.setRowCount(4200)
|
|
462
|
+
expect(cache.rowCount()).toBe(4200)
|
|
463
|
+
expect(cache.lastRowKnown()).toBe(true)
|
|
464
|
+
expect(cache.rows()).toHaveLength(4200)
|
|
465
|
+
cache.dispose()
|
|
466
|
+
})
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
describe('createBlockCache.applyRows', () => {
|
|
470
|
+
it('writes rows into place without a fetch and settles the count', () => {
|
|
471
|
+
let fetched = 0
|
|
472
|
+
const cache = createBlockCache<Row>({
|
|
473
|
+
blockSize: 100,
|
|
474
|
+
fetch: async () => {
|
|
475
|
+
fetched += 1
|
|
476
|
+
return { rows: [] }
|
|
477
|
+
},
|
|
478
|
+
onChange: () => {},
|
|
479
|
+
schedule: sync,
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
const rows: Row[] = Array.from({ length: 250 }, (_, id) => ({ id }))
|
|
483
|
+
cache.applyRows(0, rows, 250)
|
|
484
|
+
|
|
485
|
+
expect(fetched).toBe(0)
|
|
486
|
+
expect(cache.rowCount()).toBe(250)
|
|
487
|
+
expect(cache.lastRowKnown()).toBe(true)
|
|
488
|
+
expect(cache.getRow(0)).toEqual({ id: 0 })
|
|
489
|
+
expect(cache.getRow(249)).toEqual({ id: 249 })
|
|
490
|
+
expect(cache.getCacheState().map((b) => b.status)).toEqual(['loaded', 'loaded', 'loaded'])
|
|
491
|
+
// Now that those blocks are held, the viewport asks for nothing.
|
|
492
|
+
cache.setViewport(0, 249)
|
|
493
|
+
expect(fetched).toBe(0)
|
|
494
|
+
cache.dispose()
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('discovers the end from a short final slice when no count is given', () => {
|
|
498
|
+
const cache = createBlockCache<Row>({
|
|
499
|
+
blockSize: 100,
|
|
500
|
+
fetch: async () => ({ rows: [] }),
|
|
501
|
+
onChange: () => {},
|
|
502
|
+
schedule: sync,
|
|
503
|
+
})
|
|
504
|
+
cache.applyRows(0, Array.from({ length: 130 }, (_, id) => ({ id })))
|
|
505
|
+
expect(cache.rowCount()).toBe(130)
|
|
506
|
+
cache.dispose()
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('refuses a start that is not on a block boundary', () => {
|
|
510
|
+
const cache = createBlockCache<Row>({
|
|
511
|
+
blockSize: 100,
|
|
512
|
+
fetch: async () => ({ rows: [] }),
|
|
513
|
+
onChange: () => {},
|
|
514
|
+
schedule: sync,
|
|
515
|
+
})
|
|
516
|
+
expect(() => cache.applyRows(50, [{ id: 50 }])).toThrow(/block boundary|multiple/)
|
|
517
|
+
cache.dispose()
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
describe('createBlockCache before anything has loaded', () => {
|
|
522
|
+
it('claims only initialRowCount rows while the first block is in flight', () => {
|
|
523
|
+
const server = scriptedFetch()
|
|
524
|
+
const cache = createBlockCache<Row>({
|
|
525
|
+
blockSize: 100,
|
|
526
|
+
initialRowCount: 3,
|
|
527
|
+
fetch: server.fetch,
|
|
528
|
+
onChange: () => {},
|
|
529
|
+
schedule: sync,
|
|
530
|
+
})
|
|
531
|
+
cache.setViewport(0, 2)
|
|
532
|
+
// The request is out, but a level waiting on its first block shows three
|
|
533
|
+
// skeleton rows, not a hundred - the block's size is not yet a fact.
|
|
534
|
+
expect(cache.rows()).toHaveLength(3)
|
|
535
|
+
expect(cache.getCacheState()[0]!.status).toBe('loading')
|
|
536
|
+
cache.dispose()
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('lets a loading block extend the list once real data is in place', async () => {
|
|
540
|
+
const pending: Array<() => void> = []
|
|
541
|
+
const fetch = (startRow: number, endRow: number) =>
|
|
542
|
+
new Promise<{ rows: Row[] }>((resolve) => {
|
|
543
|
+
pending.push(() => {
|
|
544
|
+
const rows: Row[] = []
|
|
545
|
+
for (let i = startRow; i < endRow; i += 1) rows.push({ id: i })
|
|
546
|
+
resolve({ rows }) // never a count: the end is unknown
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
const cache = createBlockCache<Row>({
|
|
550
|
+
blockSize: 100,
|
|
551
|
+
initialRowCount: 1,
|
|
552
|
+
fetch,
|
|
553
|
+
onChange: () => {},
|
|
554
|
+
schedule: sync,
|
|
555
|
+
})
|
|
556
|
+
cache.setViewport(0, 0)
|
|
557
|
+
pending.shift()!()
|
|
558
|
+
await settle()
|
|
559
|
+
expect(cache.rows()).toHaveLength(101) // block 0 loaded, one row of runway
|
|
560
|
+
|
|
561
|
+
// Scrolling into the runway requests block 1; while it is in flight the
|
|
562
|
+
// list already spans it, so the scrollbar does not shrink back.
|
|
563
|
+
cache.setViewport(100, 100)
|
|
564
|
+
expect(cache.rows()).toHaveLength(201)
|
|
565
|
+
cache.dispose()
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
describe('createBlockCache edits in the middle of a table', () => {
|
|
570
|
+
/** Load blocks 3 and 4 only, the way a user who scrolled there would have. */
|
|
571
|
+
async function midTable() {
|
|
572
|
+
const server = scriptedFetch({ total: 1000 })
|
|
573
|
+
const cache = createBlockCache<Row>({
|
|
574
|
+
blockSize: 100,
|
|
575
|
+
fetch: server.fetch,
|
|
576
|
+
onChange: () => {},
|
|
577
|
+
schedule: sync,
|
|
578
|
+
})
|
|
579
|
+
cache.setViewport(320, 450)
|
|
580
|
+
await server.flush()
|
|
581
|
+
expect(cache.getCacheState().map((b) => b.blockIndex)).toEqual([3, 4])
|
|
582
|
+
return { cache, server }
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
it('finds a loaded row by predicate and ignores unloaded ones', async () => {
|
|
586
|
+
const { cache } = await midTable()
|
|
587
|
+
expect(cache.findIndex((r) => r.id === 350)).toBe(350)
|
|
588
|
+
expect(cache.findIndex((r) => r.id === 5)).toBe(-1) // block 0 is not loaded
|
|
589
|
+
cache.dispose()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('inserts into a loaded run without needing block 0', async () => {
|
|
593
|
+
const { cache } = await midTable()
|
|
594
|
+
expect(cache.insert(350, [{ id: -1 }])).toBe(true)
|
|
595
|
+
expect(cache.getRow(350)).toEqual({ id: -1 })
|
|
596
|
+
expect(cache.getRow(351)).toEqual({ id: 350 })
|
|
597
|
+
expect(cache.rowCount()).toBe(1001)
|
|
598
|
+
// The run was two blocks (200 rows) + 1: blocks 3, 4 and a one-row 5.
|
|
599
|
+
expect(cache.getCacheState().map((b) => b.blockIndex)).toEqual([3, 4, 5])
|
|
600
|
+
cache.dispose()
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('removes from a loaded run and reports how many went', async () => {
|
|
604
|
+
const { cache } = await midTable()
|
|
605
|
+
expect(cache.remove(400, 2)).toBe(2)
|
|
606
|
+
expect(cache.getRow(400)).toEqual({ id: 402 })
|
|
607
|
+
expect(cache.rowCount()).toBe(998)
|
|
608
|
+
cache.dispose()
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('only grows the count for an insert into an unloaded region', async () => {
|
|
612
|
+
const { cache } = await midTable()
|
|
613
|
+
expect(cache.insert(50, [{ id: -1 }])).toBe(false)
|
|
614
|
+
expect(cache.rowCount()).toBe(1001)
|
|
615
|
+
// Blocks 3 and 4 sit past the insert point, so their offsets moved: gone.
|
|
616
|
+
expect(cache.getCacheState()).toEqual([])
|
|
617
|
+
cache.dispose()
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('refuses to remove what it does not hold', async () => {
|
|
621
|
+
const { cache } = await midTable()
|
|
622
|
+
expect(cache.remove(50, 1)).toBe(0)
|
|
623
|
+
expect(cache.rowCount()).toBe(1000)
|
|
624
|
+
cache.dispose()
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
describe('createBlockCache read-after-write', () => {
|
|
629
|
+
it('shows a patched row on the very next synchronous read', async () => {
|
|
630
|
+
const server = scriptedFetch()
|
|
631
|
+
const cache = createBlockCache<Row>({
|
|
632
|
+
blockSize: 100,
|
|
633
|
+
fetch: server.fetch,
|
|
634
|
+
onChange: () => {},
|
|
635
|
+
// The default microtask emit, deliberately: the point is that a read
|
|
636
|
+
// between the mutation and the notification is still correct.
|
|
637
|
+
})
|
|
638
|
+
cache.setViewport(0, 10)
|
|
639
|
+
await server.flush()
|
|
640
|
+
cache.rows() // prime the cached array
|
|
641
|
+
cache.patch(3, { id: 999 })
|
|
642
|
+
expect(cache.rows()[3]).toEqual({ id: 999 })
|
|
643
|
+
cache.dispose()
|
|
644
|
+
})
|
|
645
|
+
})
|