@svgrid/grid 3.0.4 → 3.0.6

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 (176) hide show
  1. package/README.md +32 -11
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +33 -5
  4. package/dist/SvGrid.controller.svelte.js +397 -58
  5. package/dist/SvGrid.css +200 -2
  6. package/dist/SvGrid.svelte +350 -40
  7. package/dist/SvGrid.types.d.ts +433 -7
  8. package/dist/SvGridDropdown.svelte +20 -0
  9. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  10. package/dist/SvModal.svelte +22 -0
  11. package/dist/SvModal.svelte.d.ts +7 -0
  12. package/dist/a11y/dismissable.d.ts +10 -0
  13. package/dist/a11y/dismissable.js +24 -2
  14. package/dist/build-api.js +92 -37
  15. package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-C-NtjlrJ.js} +155 -146
  16. package/dist/cdn/GridMenus-TssHej3q.js +644 -0
  17. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  18. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  19. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  21. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-BlyCSp0q.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-ClRRA9lW.js} +1 -1
  23. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-B4EV8U6t.js} +4 -4
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DsmwTyZF.js} +4 -4
  25. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-cHloHobN.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-vOl7C0Za.js} +4 -4
  27. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-AcqF-Ll5.js} +5 -5
  28. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-biMtSBPA.js} +5 -5
  29. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-Dx_GmZuL.js} +2 -2
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-I3rDHXAq.js} +2 -2
  31. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  32. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  33. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  34. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  35. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-DVVKOSI9.js} +1 -1
  36. package/dist/cdn/{chart-Dz7SqMXH.js → chart-DEZxwC0i.js} +1 -1
  37. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-D12XcTZJ.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DguS7gC5.js} +1 -1
  39. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-CMAXBGY-.js} +1 -1
  40. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-BkEp61Gj.js} +1 -1
  41. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  42. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-YzdwBww2.js} +1 -1
  43. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  44. package/dist/cdn/server-block-cache-DIqb3VZn.js +241 -0
  45. package/dist/cdn/{src-V1uu8iE9.js → src-CgoeSM8k.js} +5339 -4753
  46. package/dist/cdn/{src-BKhZ6eXd.js → src-DfwxB1TH.js} +9817 -9231
  47. package/dist/cdn/svgrid.js +28 -27
  48. package/dist/cdn/svgrid.svelte-external.js +28 -27
  49. package/dist/cdn/validate-AZoD8BoR.js +80 -0
  50. package/dist/cell-formatting.js +18 -1
  51. package/dist/cell-render.js +25 -6
  52. package/dist/clipboard.d.ts +2 -2
  53. package/dist/clipboard.js +72 -23
  54. package/dist/columns.js +7 -2
  55. package/dist/conditional-formatting.d.ts +2 -0
  56. package/dist/conditional-formatting.js +2 -0
  57. package/dist/core.d.ts +22 -6
  58. package/dist/core.js +401 -38
  59. package/dist/editing.js +34 -0
  60. package/dist/gantt-view.svelte.d.ts +24 -0
  61. package/dist/gantt-view.svelte.js +13 -0
  62. package/dist/grid-icons.d.ts +2 -2
  63. package/dist/grid-icons.js +2 -2
  64. package/dist/grid-messages.d.ts +10 -0
  65. package/dist/grid-messages.js +9 -0
  66. package/dist/headless.d.ts +5 -0
  67. package/dist/headless.js +12 -0
  68. package/dist/history.d.ts +21 -0
  69. package/dist/history.js +34 -4
  70. package/dist/index.d.ts +5 -4
  71. package/dist/index.js +3 -3
  72. package/dist/keyboard-handlers.js +24 -1
  73. package/dist/keyboard.js +4 -0
  74. package/dist/row-drag-touch.d.ts +1 -1
  75. package/dist/row-drag.js +35 -1
  76. package/dist/row-model.d.ts +139 -0
  77. package/dist/row-model.js +16 -0
  78. package/dist/row-resize.js +22 -1
  79. package/dist/selection.d.ts +2 -1
  80. package/dist/selection.js +31 -3
  81. package/dist/server-block-cache.d.ts +219 -0
  82. package/dist/server-block-cache.js +539 -0
  83. package/dist/server-data-source.d.ts +274 -8
  84. package/dist/server-data-source.js +229 -20
  85. package/dist/server.d.ts +13 -0
  86. package/dist/server.js +13 -0
  87. package/dist/sparkline.js +7 -5
  88. package/dist/svgrid-wrapper.types.d.ts +33 -0
  89. package/dist/validate.d.ts +4 -0
  90. package/dist/validate.js +29 -3
  91. package/package.json +11 -1
  92. package/src/GridMenus.svelte +34 -4
  93. package/src/SvGrid.controller.svelte.ts +397 -61
  94. package/src/SvGrid.css +200 -2
  95. package/src/SvGrid.svelte +350 -40
  96. package/src/SvGrid.types.ts +453 -7
  97. package/src/SvGridDropdown.svelte +20 -0
  98. package/src/SvModal.svelte +22 -0
  99. package/src/SvModal.test.ts +30 -1
  100. package/src/a11y/dismissable.test.ts +10 -1
  101. package/src/a11y/dismissable.ts +22 -2
  102. package/src/build-api.ts +96 -31
  103. package/src/cell-formatting.test.ts +17 -0
  104. package/src/cell-formatting.ts +18 -1
  105. package/src/cell-render.test.ts +24 -2
  106. package/src/cell-render.ts +24 -8
  107. package/src/clipboard.test.ts +107 -0
  108. package/src/clipboard.ts +62 -23
  109. package/src/columns.test.ts +4 -4
  110. package/src/columns.ts +7 -2
  111. package/src/conditional-formatting.test.ts +8 -0
  112. package/src/conditional-formatting.ts +3 -0
  113. package/src/core.rowmodel-cache.test.ts +51 -0
  114. package/src/core.tick-repair.test.ts +287 -0
  115. package/src/core.ts +407 -38
  116. package/src/editing.test.ts +52 -0
  117. package/src/editing.ts +33 -0
  118. package/src/gantt-stub.test.svelte +38 -0
  119. package/src/gantt-view.svelte.ts +35 -0
  120. package/src/grid-icons.ts +2 -2
  121. package/src/grid-messages.test.ts +7 -0
  122. package/src/grid-messages.ts +22 -0
  123. package/src/headless.ts +24 -0
  124. package/src/history.test.ts +49 -1
  125. package/src/history.ts +48 -2
  126. package/src/icon-seam.test.ts +2 -4
  127. package/src/index.ts +38 -10
  128. package/src/keyboard-handlers.coverage.test.ts +65 -0
  129. package/src/keyboard-handlers.ts +26 -1
  130. package/src/keyboard.ts +3 -0
  131. package/src/row-drag-touch.ts +2 -2
  132. package/src/row-drag.test.ts +51 -0
  133. package/src/row-drag.ts +35 -2
  134. package/src/row-model.ts +152 -0
  135. package/src/row-resize.ts +24 -1
  136. package/src/selection.test.ts +3 -0
  137. package/src/selection.ts +33 -3
  138. package/src/server-block-cache.test.ts +672 -0
  139. package/src/server-block-cache.ts +690 -0
  140. package/src/server-data-source.infinite.test.ts +405 -0
  141. package/src/server-data-source.test.ts +19 -0
  142. package/src/server-data-source.ts +496 -29
  143. package/src/server.ts +49 -0
  144. package/src/sparkline.test.ts +7 -0
  145. package/src/sparkline.ts +6 -4
  146. package/src/svgrid-wrapper.types.ts +32 -0
  147. package/src/svgrid.api.test.ts +21 -0
  148. package/src/svgrid.charting.test.ts +12 -4
  149. package/src/svgrid.context-menu.test.ts +12 -0
  150. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  151. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  152. package/src/svgrid.gantt-seam.test.ts +199 -0
  153. package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
  154. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  155. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  156. package/src/svgrid.row-model-seam.svelte.test.ts +305 -0
  157. package/src/svgrid.row-pinning.test.ts +15 -1
  158. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  159. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  160. package/src/svgrid.upsell-license.test.ts +8 -5
  161. package/src/test-setup.ts +12 -0
  162. package/src/transaction.test.ts +58 -0
  163. package/src/validate.test.ts +29 -0
  164. package/src/validate.ts +34 -3
  165. package/dist/SvGroupCell.svelte +0 -141
  166. package/dist/SvGroupCell.svelte.d.ts +0 -49
  167. package/dist/SvRowGroupPanel.svelte +0 -186
  168. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  169. package/dist/cdn/GridMenus-7kbpnnBW.js +0 -635
  170. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  171. package/dist/server-group-model.d.ts +0 -98
  172. package/dist/server-group-model.js +0 -263
  173. package/src/SvGroupCell.svelte +0 -141
  174. package/src/SvRowGroupPanel.svelte +0 -186
  175. package/src/server-group-model.test.ts +0 -294
  176. package/src/server-group-model.ts +0 -370
@@ -0,0 +1,690 @@
1
+ /**
2
+ * Block cache for server-backed rows - the engine behind infinite scrolling.
3
+ *
4
+ * Paging asks for "page 3". Infinite scrolling asks a different question: the
5
+ * user is looking at rows 4,000 to 4,020 of a table with a million rows, so
6
+ * fetch the block that covers them, keep a bounded number of recently-seen
7
+ * blocks, and render something sensible for every row nobody has fetched yet.
8
+ * That is all this module does, in plain TypeScript with no Svelte and no DOM.
9
+ *
10
+ * The shape of the problem (and most of the vocabulary) is the same one every
11
+ * grid with a server row model solves, so the options here are deliberately
12
+ * recognisable: `blockSize`, `maxBlocksInCache`, `maxConcurrentRequests`,
13
+ * `blockLoadDebounceMs`, `initialRowCount`, `overflowRows`.
14
+ *
15
+ * Three things it takes seriously:
16
+ *
17
+ * - **Unloaded rows still have to render.** `rows()` always returns a dense
18
+ * array, with a shared sentinel object in every slot that has no data. The
19
+ * grid recognises those (see {@link rowPlaceholderState}) and draws a
20
+ * skeleton or a retry affordance instead of an empty row, so the scrollbar
21
+ * never lies and scrolling never leaves a hole.
22
+ * - **The row count may be unknown.** A backend that cannot cheaply count
23
+ * says so (omit `rowCount`, or send `-1`) and the list grows by a block at
24
+ * a time until a short block proves where the end is.
25
+ * - **Requests are not free.** In-flight blocks are deduplicated, at most
26
+ * `maxConcurrentRequests` are open at once, a fast scroll debounces rather
27
+ * than firing a request per frame, and blocks abandoned by eviction or
28
+ * purge are aborted.
29
+ *
30
+ * Enterprise's server row model runs one of these per group level; the free
31
+ * flat controller runs exactly one.
32
+ */
33
+
34
+ /**
35
+ * The mark that says "this row is not data yet".
36
+ *
37
+ * A symbol, for two reasons. It cannot collide with a field in anyone's row
38
+ * data, which a string key like `__svPlaceholder` eventually would. And
39
+ * `Symbol.for` puts it in the global registry, so a placeholder still reads as
40
+ * one after it has crossed a module boundary - two copies of this file loaded
41
+ * through different specifiers would otherwise mint different symbols.
42
+ */
43
+ const PLACEHOLDER = Symbol.for('svgrid.rowPlaceholder')
44
+
45
+ /** Rows the cache has not loaded. One shared object per state, not per row. */
46
+ const LOADING_ROW = Object.freeze({ [PLACEHOLDER]: 'loading' as const })
47
+ const FAILED_ROW = Object.freeze({ [PLACEHOLDER]: 'failed' as const })
48
+
49
+ /**
50
+ * Why a row is not real data, or `null` when it is.
51
+ *
52
+ * Deliberately NOT an identity check. The sentinels are shared objects, so
53
+ * `row === LOADING_ROW` looks like the obvious test - but rows routinely
54
+ * arrive here through something that wrapped them. Svelte is the everyday
55
+ * case: assigning the controller state into `$state` makes the row array
56
+ * deeply reactive, and every row read back out is a proxy of the original.
57
+ * Identity fails, every placeholder reads as real data, and the grid renders
58
+ * a screen of blank rows instead of skeletons. Reading the mark works through
59
+ * any wrapper that forwards property access, which is all of them.
60
+ */
61
+ export function rowPlaceholderState(row: unknown): 'loading' | 'failed' | null {
62
+ if (row == null || typeof row !== 'object') return null
63
+ const mark = (row as Record<symbol, unknown>)[PLACEHOLDER]
64
+ return mark === 'loading' || mark === 'failed' ? mark : null
65
+ }
66
+
67
+ /**
68
+ * Mint a frozen placeholder the grid will recognise, carrying whatever
69
+ * other fields the caller wants on it. The row model uses this to give its
70
+ * placeholders a `kind`, so code reading display rows and code reading grid
71
+ * rows agree on what they are looking at.
72
+ */
73
+ export function createRowPlaceholder<T extends object>(
74
+ state: 'loading' | 'failed',
75
+ fields: T,
76
+ ): Readonly<T & { readonly [PLACEHOLDER]: 'loading' | 'failed' }> {
77
+ return Object.freeze({ ...fields, [PLACEHOLDER]: state })
78
+ }
79
+
80
+ /** What a block fetch has to answer with. `rowCount` is optional: see {@link BlockCacheOptions}. */
81
+ export type BlockFetchResult<TData> = {
82
+ rows: ReadonlyArray<TData>
83
+ /**
84
+ * Total rows after filtering. Omit it (or send `-1`) when the backend cannot
85
+ * count cheaply - the cache then discovers the end from the first short
86
+ * block, and `lastRowKnown()` stays false until it does.
87
+ */
88
+ rowCount?: number
89
+ }
90
+
91
+ /** What a block cache is built from: block size, caps, debounce and the fetch. */
92
+ export type BlockCacheOptions<TData> = {
93
+ /**
94
+ * Rows per request. Bigger blocks mean fewer round trips and more wasted
95
+ * rows when the user scrolls past; 100 is the usual compromise.
96
+ */
97
+ blockSize?: number
98
+ /**
99
+ * Keep at most this many loaded blocks. Blocks outside the viewport are
100
+ * evicted least-recently-seen first, and scrolling back re-fetches them.
101
+ * Unlimited by default, which is right until the dataset is big enough that
102
+ * holding every visited block matters.
103
+ */
104
+ maxBlocksInCache?: number
105
+ /** Requests open at once. Default 2. */
106
+ maxConcurrentRequests?: number
107
+ /**
108
+ * Wait this long after the viewport settles before fetching. Non-zero values
109
+ * stop a drag of the scrollbar from requesting every block it flies past.
110
+ */
111
+ blockLoadDebounceMs?: number
112
+ /**
113
+ * Rows to claim before anything is loaded, so the grid has a scrollbar (and
114
+ * `scrollToRow` has somewhere to land) on the first paint. Default 1.
115
+ */
116
+ initialRowCount?: number
117
+ /**
118
+ * While the total is unknown, how many unloaded rows to keep past the last
119
+ * loaded one. Scrolling into them is what asks for the next block. Default 1.
120
+ */
121
+ overflowRows?: number
122
+ /** Fetch one block. Reject, or throw, to mark it failed. */
123
+ fetch: (
124
+ startRow: number,
125
+ endRow: number,
126
+ signal: AbortSignal,
127
+ ) => Promise<BlockFetchResult<TData>>
128
+ /** Called after any change to the rows, the count, or a block's state. */
129
+ onChange: (cache: BlockCacheState) => void
130
+ /**
131
+ * How to coalesce `onChange`. Defaults to a microtask, so a burst of block
132
+ * arrivals rebuilds the row array once. Pass `(fn) => fn()` in tests to make
133
+ * every change synchronous.
134
+ */
135
+ schedule?: (flush: () => void) => void
136
+ }
137
+
138
+ /** One block's place in the world, for diagnostics and for tests. */
139
+ export type BlockState = {
140
+ blockIndex: number
141
+ startRow: number
142
+ endRow: number
143
+ status: 'loading' | 'loaded' | 'failed'
144
+ /** Monotonic counter of when the viewport last covered this block. */
145
+ lastTouched: number
146
+ }
147
+
148
+ /** One block as `getCacheState()` reports it. */
149
+ export type BlockCacheState = {
150
+ /** Rows after filtering, or `null` while the backend has not said. */
151
+ rowCount: number | null
152
+ /** False while the end of the data is still being discovered. */
153
+ lastRowKnown: boolean
154
+ /** True while at least one block is in flight. */
155
+ loading: boolean
156
+ /** Blocks whose fetch rejected. Empty unless something went wrong. */
157
+ failedBlocks: number[]
158
+ }
159
+
160
+ /**
161
+ * A block cache: rows arrive in blocks as the viewport reaches them,
162
+ * placeholders stand in until then, and blocks far from the viewport are
163
+ * evicted past the cap.
164
+ */
165
+ export type BlockCache<TData> = {
166
+ /**
167
+ * Tell the cache which rows are on screen. Fetches what is missing (after
168
+ * `blockLoadDebounceMs`) and marks those blocks as recently seen so eviction
169
+ * spares them. Safe to call on every scroll frame.
170
+ */
171
+ setViewport(startIndex: number, endIndex: number): void
172
+ /** The dense row array to hand the grid. Placeholders fill unloaded slots. */
173
+ rows(): ReadonlyArray<TData>
174
+ /** One row, without building the array. Returns a placeholder when unloaded. */
175
+ getRow(index: number): TData | typeof LOADING_ROW | typeof FAILED_ROW
176
+ rowCount(): number | null
177
+ lastRowKnown(): boolean
178
+ /** Re-fetch the blocks that failed. */
179
+ retryFailed(): void
180
+ /**
181
+ * Re-fetch the failed block holding `rowIndex`, and only that one; the
182
+ * Retry on a failed row. A row whose block has not failed is a no-op.
183
+ */
184
+ retryFailedAt(rowIndex: number): void
185
+ /**
186
+ * Re-fetch the loaded blocks in place, keeping the row count and scroll
187
+ * position. What you want after a mutation lands on the server.
188
+ */
189
+ refresh(): void
190
+ /** Drop everything and start over from the current viewport. */
191
+ purge(): void
192
+ /** Override the total, e.g. from a count endpoint that answered separately. */
193
+ setRowCount(count: number | null, known?: boolean): void
194
+ /**
195
+ * Write rows straight into the cache from `startRow`, as if a fetch had
196
+ * returned them, bypassing the datasource, the debounce and the
197
+ * concurrency cap. `startRow` must sit on a block boundary. An explicit
198
+ * `rowCount` settles the total; without one a short final slice does.
199
+ */
200
+ applyRows(startRow: number, rows: ReadonlyArray<TData>, rowCount?: number): void
201
+ /** Replace one loaded row. No-op when its block is not loaded. */
202
+ patch(index: number, row: TData): boolean
203
+ /**
204
+ * The index of the first LOADED row matching `predicate`, or -1. Rows
205
+ * that are not loaded are not visited - a transaction addressed by id
206
+ * can only touch what is in the cache.
207
+ */
208
+ findIndex(predicate: (row: TData, index: number) => boolean): number
209
+ /**
210
+ * Insert rows at an index and grow the count. Works within whichever
211
+ * run of loaded blocks contains `index`; loaded blocks AFTER that run
212
+ * are dropped, because their rows have shifted and will be re-fetched at
213
+ * their new offsets. An index in an unloaded region grows the count
214
+ * only (the rows exist; they arrive when scrolled to). Returns whether
215
+ * the rows were placed in the cache.
216
+ */
217
+ insert(index: number, rows: ReadonlyArray<TData>): boolean
218
+ /**
219
+ * Remove `count` rows at an index and shrink the count, with the same
220
+ * run semantics as `insert`. Returns how many rows were actually
221
+ * removed from the cache.
222
+ */
223
+ remove(index: number, count?: number): number
224
+ /** Every block the cache is holding, for `debug` output and tests. */
225
+ getCacheState(): BlockState[]
226
+ /** Abort what is in flight and stop emitting. Call on unmount. */
227
+ dispose(): void
228
+ }
229
+
230
+ type Block<TData> = {
231
+ status: 'loading' | 'loaded' | 'failed'
232
+ rows: TData[]
233
+ lastTouched: number
234
+ controller: AbortController | null
235
+ }
236
+
237
+ const defaultSchedule = (flush: () => void): void => {
238
+ void Promise.resolve().then(flush)
239
+ }
240
+
241
+ /**
242
+ * Build a block cache over a `fetch(startRow, endRow, signal)`. The free
243
+ * infinite row model and the Enterprise server-side row model both run on
244
+ * it, one per level.
245
+ */
246
+ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): BlockCache<TData> {
247
+ const blockSize = Math.max(1, options.blockSize ?? 100)
248
+ const maxBlocks = options.maxBlocksInCache ?? Infinity
249
+ const maxConcurrent = Math.max(1, options.maxConcurrentRequests ?? 2)
250
+ const debounceMs = Math.max(0, options.blockLoadDebounceMs ?? 0)
251
+ const initialRowCount = Math.max(0, options.initialRowCount ?? 1)
252
+ const overflowRows = Math.max(0, options.overflowRows ?? 1)
253
+ const schedule = options.schedule ?? defaultSchedule
254
+
255
+ const blocks = new Map<number, Block<TData>>()
256
+ /** Block indices waiting for a slot, in the order the viewport asked for them. */
257
+ const queue: number[] = []
258
+ let inFlight = 0
259
+ let touchClock = 0
260
+ let count: number | null = null
261
+ let countKnown = false
262
+ let disposed = false
263
+
264
+ let viewStart = 0
265
+ let viewEnd = 0
266
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
267
+
268
+ let emitScheduled = false
269
+ let rowsCache: TData[] | null = null
270
+
271
+ const blockOf = (rowIndex: number): number => Math.floor(rowIndex / blockSize)
272
+ const startOf = (blockIndex: number): number => blockIndex * blockSize
273
+
274
+ /**
275
+ * How many rows to claim we have.
276
+ *
277
+ * With a known count that is simply the count. Without one it is the last row
278
+ * we have touched plus `overflowRows`, so there is always somewhere to scroll
279
+ * that will ask for the next block - this is what makes the list grow as you
280
+ * go rather than ending at the first unloaded row.
281
+ */
282
+ function currentLength(): number {
283
+ if (countKnown && count != null) return count
284
+ let highestEnd = 0
285
+ let anySettled = false
286
+ for (const b of blocks.values()) if (b.status !== 'loading') anySettled = true
287
+ for (const [index, block] of blocks) {
288
+ // A block in flight extends the list only once some block has settled.
289
+ // Before that the list is `initialRowCount` long: the few skeleton
290
+ // rows a level shows while it waits, not a hundred of them. The first
291
+ // block failing says nothing about how long the list is either, so it
292
+ // keeps those few rows (its Retry sits on them); a failed block further
293
+ // in was scrolled to, so its slots stay and the scrollbar holds still.
294
+ const end = block.status === 'loaded'
295
+ ? startOf(index) + block.rows.length
296
+ : block.status === 'failed'
297
+ ? index === 0 ? 0 : startOf(index) + blockSize
298
+ : anySettled
299
+ ? startOf(index) + blockSize
300
+ : 0
301
+ if (end > highestEnd) highestEnd = end
302
+ }
303
+ if (!anySettled) return initialRowCount
304
+ return Math.max(initialRowCount, highestEnd + overflowRows)
305
+ }
306
+
307
+ function state(): BlockCacheState {
308
+ const failed: number[] = []
309
+ for (const [index, block] of blocks) if (block.status === 'failed') failed.push(index)
310
+ return {
311
+ rowCount: countKnown ? count : null,
312
+ lastRowKnown: countKnown,
313
+ loading: inFlight > 0,
314
+ failedBlocks: failed.sort((a, b) => a - b),
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Something changed: forget the row array NOW, so a synchronous read after
320
+ * a mutation sees the new state, and notify on the next tick, so a burst of
321
+ * mutations notifies once. Every mutating path ends here.
322
+ */
323
+ function emit(): void {
324
+ rowsCache = null // rebuilt lazily, on the next read
325
+ if (disposed || emitScheduled) return
326
+ emitScheduled = true
327
+ schedule(() => {
328
+ emitScheduled = false
329
+ if (disposed) return
330
+ options.onChange(state())
331
+ })
332
+ }
333
+
334
+ function ensureBlock(blockIndex: number): void {
335
+ if (disposed) return
336
+ const existing = blocks.get(blockIndex)
337
+ // Loading or loaded blocks are left alone; a failed one only retries when
338
+ // asked, so a broken backend is not hammered once per scroll frame.
339
+ if (existing) return
340
+ if (queue.includes(blockIndex)) return
341
+ queue.push(blockIndex)
342
+ pump()
343
+ }
344
+
345
+ function pump(): void {
346
+ while (!disposed && inFlight < maxConcurrent && queue.length > 0) {
347
+ const blockIndex = queue.shift()!
348
+ if (blocks.has(blockIndex)) continue
349
+ void load(blockIndex)
350
+ }
351
+ }
352
+
353
+ async function load(blockIndex: number): Promise<void> {
354
+ const controller = new AbortController()
355
+ const block: Block<TData> = {
356
+ status: 'loading',
357
+ rows: [],
358
+ lastTouched: (touchClock += 1),
359
+ controller,
360
+ }
361
+ blocks.set(blockIndex, block)
362
+ inFlight += 1
363
+ emit()
364
+
365
+ const startRow = startOf(blockIndex)
366
+ const endRow = startRow + blockSize
367
+ try {
368
+ const result = await options.fetch(startRow, endRow, controller.signal)
369
+ if (disposed || controller.signal.aborted || blocks.get(blockIndex) !== block) return
370
+ block.rows = [...result.rows]
371
+ block.status = 'loaded'
372
+ block.controller = null
373
+ applyCountFrom(blockIndex, block.rows.length, result.rowCount)
374
+ evictIfNeeded()
375
+ } catch {
376
+ if (disposed || controller.signal.aborted || blocks.get(blockIndex) !== block) return
377
+ block.status = 'failed'
378
+ block.rows = []
379
+ block.controller = null
380
+ } finally {
381
+ if (!disposed && !controller.signal.aborted) {
382
+ inFlight -= 1
383
+ emit()
384
+ pump()
385
+ }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Learn the total from a block that just landed.
391
+ *
392
+ * An explicit non-negative `rowCount` settles it. Otherwise a SHORT block is
393
+ * the proof that we hit the end: fewer rows came back than were asked for, so
394
+ * the data stops where they stop.
395
+ */
396
+ function applyCountFrom(blockIndex: number, received: number, reported: number | undefined): void {
397
+ if (typeof reported === 'number' && reported >= 0) {
398
+ count = reported
399
+ countKnown = true
400
+ return
401
+ }
402
+ if (received < blockSize) {
403
+ count = startOf(blockIndex) + received
404
+ countKnown = true
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Evict loaded blocks over the limit, least-recently-seen first, never one
410
+ * the viewport is currently on - dropping a visible block would swap real
411
+ * rows for skeletons under the user's eyes and immediately re-fetch them.
412
+ */
413
+ function evictIfNeeded(): void {
414
+ if (!Number.isFinite(maxBlocks)) return
415
+ const firstVisible = blockOf(viewStart)
416
+ const lastVisible = blockOf(Math.max(viewStart, viewEnd))
417
+ const evictable = [...blocks.entries()]
418
+ .filter(([index, b]) => b.status === 'loaded' && (index < firstVisible || index > lastVisible))
419
+ .sort((a, b) => a[1].lastTouched - b[1].lastTouched)
420
+
421
+ let loaded = 0
422
+ for (const b of blocks.values()) if (b.status === 'loaded') loaded += 1
423
+
424
+ for (const [index] of evictable) {
425
+ if (loaded <= maxBlocks) break
426
+ blocks.delete(index)
427
+ loaded -= 1
428
+ }
429
+ }
430
+
431
+ function fetchViewport(): void {
432
+ if (disposed) return
433
+ const first = blockOf(viewStart)
434
+ const last = blockOf(Math.max(viewStart, viewEnd))
435
+ for (let i = first; i <= last; i += 1) {
436
+ const block = blocks.get(i)
437
+ if (block) block.lastTouched = touchClock += 1
438
+ else ensureBlock(i)
439
+ }
440
+ }
441
+
442
+ function abortAll(): void {
443
+ for (const block of blocks.values()) block.controller?.abort()
444
+ inFlight = 0
445
+ queue.length = 0
446
+ }
447
+
448
+ return {
449
+ setViewport(startIndex, endIndex) {
450
+ if (disposed) return
451
+ viewStart = Math.max(0, startIndex)
452
+ viewEnd = Math.max(viewStart, endIndex)
453
+ if (debounceMs === 0) {
454
+ fetchViewport()
455
+ return
456
+ }
457
+ if (debounceTimer) clearTimeout(debounceTimer)
458
+ debounceTimer = setTimeout(() => {
459
+ debounceTimer = null
460
+ fetchViewport()
461
+ }, debounceMs)
462
+ },
463
+
464
+ rows() {
465
+ if (rowsCache) return rowsCache
466
+ const length = currentLength()
467
+ const out = new Array<TData>(length)
468
+ // Fill from the blocks we have, then pad the rest with placeholders. One
469
+ // pass per block beats one lookup per row: at a million rows the
470
+ // difference is the frame budget.
471
+ out.fill(LOADING_ROW as unknown as TData)
472
+ for (const [index, block] of blocks) {
473
+ const start = startOf(index)
474
+ if (start >= length) continue
475
+ if (block.status === 'failed') {
476
+ const end = Math.min(start + blockSize, length)
477
+ for (let i = start; i < end; i += 1) out[i] = FAILED_ROW as unknown as TData
478
+ continue
479
+ }
480
+ for (let i = 0; i < block.rows.length && start + i < length; i += 1) {
481
+ out[start + i] = block.rows[i]!
482
+ }
483
+ }
484
+ rowsCache = out
485
+ return out
486
+ },
487
+
488
+ getRow(index) {
489
+ const block = blocks.get(blockOf(index))
490
+ if (!block) return LOADING_ROW
491
+ if (block.status === 'failed') return FAILED_ROW
492
+ const row = block.rows[index - startOf(blockOf(index))]
493
+ return row ?? LOADING_ROW
494
+ },
495
+
496
+ rowCount: () => (countKnown ? count : null),
497
+ lastRowKnown: () => countKnown,
498
+
499
+ retryFailed() {
500
+ let any = false
501
+ for (const [index, block] of [...blocks]) {
502
+ if (block.status !== 'failed') continue
503
+ blocks.delete(index)
504
+ ensureBlock(index)
505
+ any = true
506
+ }
507
+ if (any) emit()
508
+ },
509
+
510
+ retryFailedAt(rowIndex) {
511
+ const index = blockOf(Math.max(0, rowIndex))
512
+ if (blocks.get(index)?.status !== 'failed') return
513
+ blocks.delete(index)
514
+ ensureBlock(index)
515
+ emit()
516
+ },
517
+
518
+ refresh() {
519
+ // Keep the count and the scroll position: drop the DATA, re-request the
520
+ // same blocks. The user sees skeletons where they were, not a jump home.
521
+ for (const [index, block] of [...blocks]) {
522
+ block.controller?.abort()
523
+ blocks.delete(index)
524
+ ensureBlock(index)
525
+ }
526
+ emit()
527
+ },
528
+
529
+ purge() {
530
+ abortAll()
531
+ blocks.clear()
532
+ count = null
533
+ countKnown = false
534
+ rowsCache = null
535
+ fetchViewport()
536
+ emit()
537
+ },
538
+
539
+ setRowCount(next, known = next != null) {
540
+ count = next
541
+ countKnown = known && next != null
542
+ emit()
543
+ },
544
+
545
+ applyRows(startRow, rows, rowCount) {
546
+ if (startRow % blockSize !== 0) {
547
+ throw new Error(
548
+ `createBlockCache.applyRows: startRow ${startRow} is not a multiple of blockSize ${blockSize}`,
549
+ )
550
+ }
551
+ const firstBlock = blockOf(startRow)
552
+ const blockCount = Math.ceil(rows.length / blockSize)
553
+ for (let i = 0; i < blockCount; i += 1) {
554
+ const index = firstBlock + i
555
+ blocks.get(index)?.controller?.abort() // ours now, whatever was in flight
556
+ blocks.set(index, {
557
+ status: 'loaded',
558
+ rows: rows.slice(i * blockSize, (i + 1) * blockSize),
559
+ lastTouched: (touchClock += 1),
560
+ controller: null,
561
+ })
562
+ }
563
+ const lastBlock = blocks.get(firstBlock + blockCount - 1)
564
+ if (lastBlock) applyCountFrom(firstBlock + blockCount - 1, lastBlock.rows.length, rowCount)
565
+ else if (typeof rowCount === 'number' && rowCount >= 0) {
566
+ count = rowCount
567
+ countKnown = true
568
+ }
569
+ emit()
570
+ },
571
+
572
+ patch(index, row) {
573
+ const block = blocks.get(blockOf(index))
574
+ if (!block || block.status !== 'loaded') return false
575
+ const offset = index - startOf(blockOf(index))
576
+ if (offset < 0 || offset >= block.rows.length) return false
577
+ block.rows[offset] = row
578
+ emit()
579
+ return true
580
+ },
581
+
582
+ findIndex(predicate) {
583
+ for (const [blockIndex, block] of blocks) {
584
+ if (block.status !== 'loaded') continue
585
+ const start = startOf(blockIndex)
586
+ for (let i = 0; i < block.rows.length; i += 1) {
587
+ if (predicate(block.rows[i]!, start + i)) return start + i
588
+ }
589
+ }
590
+ return -1
591
+ },
592
+
593
+ insert(index, rows) {
594
+ if (rows.length === 0) return false
595
+ const clamped = Math.max(0, Math.min(index, currentLength()))
596
+ const run = loadedRunAt(clamped)
597
+ if (run) {
598
+ // Rows shift across block boundaries, so re-slice the run from its
599
+ // first block; everything loaded past it is dropped (offsets moved).
600
+ run.flat.splice(clamped - startOf(run.startBlock), 0, ...rows)
601
+ reseat(run.startBlock, run.flat)
602
+ } else {
603
+ dropLoadedFrom(blockOf(clamped))
604
+ }
605
+ if (countKnown && count != null) count += rows.length
606
+ emit()
607
+ return run != null
608
+ },
609
+
610
+ remove(index, removeCount = 1) {
611
+ if (removeCount <= 0) return 0
612
+ const run = loadedRunAt(index)
613
+ if (!run) return 0
614
+ const removed = run.flat.splice(index - startOf(run.startBlock), removeCount).length
615
+ reseat(run.startBlock, run.flat)
616
+ if (countKnown && count != null) count = Math.max(0, count - removed)
617
+ emit()
618
+ return removed
619
+ },
620
+
621
+ getCacheState() {
622
+ return [...blocks.entries()]
623
+ .map(([blockIndex, block]) => ({
624
+ blockIndex,
625
+ startRow: startOf(blockIndex),
626
+ endRow: startOf(blockIndex) + blockSize,
627
+ status: block.status,
628
+ lastTouched: block.lastTouched,
629
+ }))
630
+ .sort((a, b) => a.blockIndex - b.blockIndex)
631
+ },
632
+
633
+ dispose() {
634
+ disposed = true
635
+ if (debounceTimer) clearTimeout(debounceTimer)
636
+ debounceTimer = null
637
+ abortAll()
638
+ blocks.clear()
639
+ },
640
+ }
641
+
642
+ /**
643
+ * The run of consecutive loaded blocks containing `index`, flattened, with
644
+ * the block it starts at. Null when the block at `index` is not loaded -
645
+ * an index-shifting edit there would have nothing to shift.
646
+ */
647
+ function loadedRunAt(index: number): { startBlock: number; flat: TData[] } | null {
648
+ const at = blockOf(index)
649
+ if (blocks.get(at)?.status !== 'loaded') return null
650
+ let startBlock = at
651
+ while (blocks.get(startBlock - 1)?.status === 'loaded') startBlock -= 1
652
+ const flat: TData[] = []
653
+ for (let i = startBlock; ; i += 1) {
654
+ const block = blocks.get(i)
655
+ if (!block || block.status !== 'loaded') break
656
+ flat.push(...block.rows)
657
+ if (block.rows.length < blockSize) break // the last block, by definition
658
+ }
659
+ return { startBlock, flat }
660
+ }
661
+
662
+ /**
663
+ * Lay a flat run back out into blocks from `startBlock`, and drop every
664
+ * block after the run: their rows have shifted, and re-fetching them at
665
+ * the new offsets is the honest outcome. Blocks before it are untouched.
666
+ */
667
+ function reseat(startBlock: number, flat: TData[]): void {
668
+ const blockCount = Math.ceil(flat.length / blockSize)
669
+ dropLoadedFrom(startBlock)
670
+ for (let i = 0; i < blockCount; i += 1) {
671
+ blocks.set(startBlock + i, {
672
+ status: 'loaded',
673
+ rows: flat.slice(i * blockSize, (i + 1) * blockSize),
674
+ lastTouched: (touchClock += 1),
675
+ controller: null,
676
+ })
677
+ }
678
+ rowsCache = null
679
+ }
680
+
681
+ /** Forget every block from `fromBlock` on, aborting any in flight. */
682
+ function dropLoadedFrom(fromBlock: number): void {
683
+ for (const [index, block] of [...blocks]) {
684
+ if (index < fromBlock) continue
685
+ block.controller?.abort()
686
+ blocks.delete(index)
687
+ }
688
+ rowsCache = null
689
+ }
690
+ }