@svgrid/grid 3.0.3 → 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.
Files changed (318) hide show
  1. package/README.md +5 -4
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +122 -41
  8. package/dist/SvGrid.controller.svelte.js +845 -207
  9. package/dist/SvGrid.css +178 -2
  10. package/dist/SvGrid.svelte +467 -516
  11. package/dist/SvGrid.types.d.ts +315 -12
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +119 -28
  29. package/dist/cdn/GridMenus-CLI93jke.js +635 -0
  30. package/dist/cdn/{GridMenus-DhdPF700.js → GridMenus-CwooL3QA.js} +240 -207
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/server-block-cache-CGoWz-87.js +237 -0
  67. package/dist/cdn/{src-D6XvA1Ij.js → src-B6HXdfHY.js} +10448 -10049
  68. package/dist/cdn/{src-Bugf5XlI.js → src-C6Iowdvs.js} +6908 -6509
  69. package/dist/cdn/svgrid.js +27 -15
  70. package/dist/cdn/svgrid.svelte-external.js +27 -15
  71. package/dist/cdn/validate-CchXzyrX.js +76 -0
  72. package/dist/chart-axes.d.ts +53 -0
  73. package/dist/chart-axes.js +351 -0
  74. package/dist/chart-cartesian.d.ts +88 -0
  75. package/dist/chart-cartesian.js +1862 -0
  76. package/dist/chart-decimate.d.ts +51 -0
  77. package/dist/chart-decimate.js +199 -0
  78. package/dist/chart-export-pdf.d.ts +47 -0
  79. package/dist/chart-export-pdf.js +187 -0
  80. package/dist/chart-export.d.ts +2 -1
  81. package/dist/chart-export.js +18 -3
  82. package/dist/chart-financial.d.ts +91 -0
  83. package/dist/chart-financial.js +175 -0
  84. package/dist/chart-flow.d.ts +10 -0
  85. package/dist/chart-flow.js +298 -0
  86. package/dist/chart-format.d.ts +18 -0
  87. package/dist/chart-format.js +65 -0
  88. package/dist/chart-grid.d.ts +8 -0
  89. package/dist/chart-grid.js +190 -0
  90. package/dist/chart-hierarchy.d.ts +17 -0
  91. package/dist/chart-hierarchy.js +146 -0
  92. package/dist/chart-indicators.d.ts +115 -0
  93. package/dist/chart-indicators.js +389 -0
  94. package/dist/chart-messages.d.ts +95 -0
  95. package/dist/chart-messages.js +92 -0
  96. package/dist/chart-motion.d.ts +16 -0
  97. package/dist/chart-motion.js +141 -0
  98. package/dist/chart-panel-messages.d.ts +265 -0
  99. package/dist/chart-panel-messages.js +285 -0
  100. package/dist/chart-pivot.d.ts +87 -0
  101. package/dist/chart-pivot.js +156 -0
  102. package/dist/chart-polar.d.ts +25 -0
  103. package/dist/chart-polar.js +700 -0
  104. package/dist/chart-samples.d.ts +16 -0
  105. package/dist/chart-samples.js +153 -0
  106. package/dist/chart-scale.d.ts +113 -0
  107. package/dist/chart-scale.js +405 -0
  108. package/dist/chart-stats.d.ts +111 -0
  109. package/dist/chart-stats.js +419 -0
  110. package/dist/chart-stream.d.ts +45 -0
  111. package/dist/chart-stream.js +91 -0
  112. package/dist/chart-summary.d.ts +27 -0
  113. package/dist/chart-summary.js +205 -0
  114. package/dist/chart-sync.svelte.d.ts +32 -0
  115. package/dist/chart-sync.svelte.js +23 -0
  116. package/dist/chart-table.d.ts +35 -0
  117. package/dist/chart-table.js +165 -0
  118. package/dist/chart-trend.d.ts +19 -0
  119. package/dist/chart-trend.js +61 -0
  120. package/dist/chart-types.d.ts +1456 -0
  121. package/dist/chart-types.js +6 -0
  122. package/dist/chart-validate.d.ts +43 -0
  123. package/dist/chart-validate.js +272 -0
  124. package/dist/chart-zoom.d.ts +40 -0
  125. package/dist/chart-zoom.js +145 -0
  126. package/dist/chart.d.ts +186 -865
  127. package/dist/chart.js +460 -2204
  128. package/dist/clipboard.d.ts +1 -0
  129. package/dist/clipboard.js +137 -18
  130. package/dist/column-resize.d.ts +3 -0
  131. package/dist/column-resize.js +4 -1
  132. package/dist/columns.js +3 -0
  133. package/dist/command-context.d.ts +19 -0
  134. package/dist/command-context.js +116 -0
  135. package/dist/core.d.ts +7 -0
  136. package/dist/core.js +36 -13
  137. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  138. package/dist/editing.d.ts +2 -8
  139. package/dist/editing.js +272 -33
  140. package/dist/fill-patterns.d.ts +0 -5
  141. package/dist/fill-patterns.js +56 -2
  142. package/dist/grid-icons.d.ts +2 -2
  143. package/dist/grid-icons.js +2 -2
  144. package/dist/grid-messages.d.ts +12 -3
  145. package/dist/grid-messages.js +7 -0
  146. package/dist/history.d.ts +83 -0
  147. package/dist/history.js +133 -0
  148. package/dist/index.d.ts +27 -10
  149. package/dist/index.js +22 -5
  150. package/dist/keyboard-handlers.js +88 -31
  151. package/dist/keyboard.d.ts +55 -0
  152. package/dist/keyboard.js +132 -0
  153. package/dist/menus.d.ts +3 -1
  154. package/dist/menus.js +7 -2
  155. package/dist/merges.d.ts +97 -0
  156. package/dist/merges.js +147 -0
  157. package/dist/row-model.d.ts +133 -0
  158. package/dist/row-model.js +16 -0
  159. package/dist/row-resize.d.ts +3 -0
  160. package/dist/row-resize.js +14 -0
  161. package/dist/scroll-sync.d.ts +1 -0
  162. package/dist/scroll-sync.js +24 -0
  163. package/dist/selection.d.ts +18 -1
  164. package/dist/selection.js +138 -18
  165. package/dist/server-block-cache.d.ts +214 -0
  166. package/dist/server-block-cache.js +531 -0
  167. package/dist/server-data-source.d.ts +240 -6
  168. package/dist/server-data-source.js +220 -20
  169. package/dist/server.d.ts +13 -0
  170. package/dist/server.js +13 -0
  171. package/dist/shortcut-registry.d.ts +145 -0
  172. package/dist/shortcut-registry.js +44 -0
  173. package/dist/svgrid-wrapper.types.d.ts +213 -22
  174. package/dist/validate.d.ts +2 -0
  175. package/dist/validate.js +8 -3
  176. package/dist/virtualization/virtualizer.js +38 -17
  177. package/package.json +11 -1
  178. package/src/GridMenus.svelte +44 -2
  179. package/src/SvChartMenu.svelte +69 -0
  180. package/src/SvChartPanes.svelte +113 -0
  181. package/src/SvChartPanes.test.ts +66 -0
  182. package/src/SvGrid.controller.svelte.ts +818 -179
  183. package/src/SvGrid.css +178 -2
  184. package/src/SvGrid.svelte +467 -516
  185. package/src/SvGrid.types.ts +293 -11
  186. package/src/SvGridCellEditor.svelte +17 -0
  187. package/src/SvGridChart.svelte +2257 -329
  188. package/src/SvGridChart.test.ts +1419 -1
  189. package/src/SvGridChart.types.ts +336 -0
  190. package/src/SvGridChartBuilder.svelte +323 -0
  191. package/src/SvGridChartPanel.svelte +221 -275
  192. package/src/SvGridChartPickers.svelte +417 -0
  193. package/src/SvGridChartView.svelte +13 -14
  194. package/src/SvModal.svelte +7 -1
  195. package/src/SvModal.test.ts +9 -0
  196. package/src/ai.test.ts +57 -2
  197. package/src/ai.ts +166 -22
  198. package/src/aria.d.ts +11 -0
  199. package/src/build-api.coverage.test.ts +184 -0
  200. package/src/build-api.ts +118 -22
  201. package/src/chart-axes.test.ts +573 -0
  202. package/src/chart-axes.ts +342 -0
  203. package/src/chart-cartesian.ts +1863 -0
  204. package/src/chart-decimate.test.ts +249 -0
  205. package/src/chart-decimate.ts +186 -0
  206. package/src/chart-export-pdf.test.ts +91 -0
  207. package/src/chart-export-pdf.ts +207 -0
  208. package/src/chart-export.test.ts +14 -0
  209. package/src/chart-export.ts +13 -3
  210. package/src/chart-financial.ts +208 -0
  211. package/src/chart-flow.ts +286 -0
  212. package/src/chart-format.ts +67 -0
  213. package/src/chart-grid.ts +179 -0
  214. package/src/chart-hierarchy.ts +135 -0
  215. package/src/chart-indicators.test.ts +443 -0
  216. package/src/chart-indicators.ts +384 -0
  217. package/src/chart-messages.test.ts +21 -0
  218. package/src/chart-messages.ts +184 -0
  219. package/src/chart-motion.test.ts +71 -0
  220. package/src/chart-motion.ts +148 -0
  221. package/src/chart-panel-messages.test.ts +29 -0
  222. package/src/chart-panel-messages.ts +550 -0
  223. package/src/chart-pivot.test.ts +54 -0
  224. package/src/chart-pivot.ts +225 -0
  225. package/src/chart-polar.ts +674 -0
  226. package/src/chart-samples.ts +106 -0
  227. package/src/chart-scale.ts +394 -0
  228. package/src/chart-series-types.test.ts +714 -0
  229. package/src/chart-stats.ts +409 -0
  230. package/src/chart-stream.test.ts +75 -0
  231. package/src/chart-stream.ts +114 -0
  232. package/src/chart-summary.test.ts +90 -0
  233. package/src/chart-summary.ts +197 -0
  234. package/src/chart-sync.svelte.ts +41 -0
  235. package/src/chart-table.test.ts +76 -0
  236. package/src/chart-table.ts +178 -0
  237. package/src/chart-trend.ts +47 -0
  238. package/src/chart-types.ts +1309 -0
  239. package/src/chart-validate.test.ts +100 -0
  240. package/src/chart-validate.ts +247 -0
  241. package/src/chart-zoom.test.ts +137 -0
  242. package/src/chart-zoom.ts +124 -0
  243. package/src/chart.coverage.test.ts +81 -0
  244. package/src/chart.test.ts +56 -3
  245. package/src/chart.ts +557 -2784
  246. package/src/clipboard.test.ts +151 -0
  247. package/src/clipboard.ts +145 -16
  248. package/src/column-resize.ts +7 -1
  249. package/src/columns.ts +2 -0
  250. package/src/command-context.test.ts +200 -0
  251. package/src/command-context.ts +137 -0
  252. package/src/core.rowmodel-cache.test.ts +51 -0
  253. package/src/core.ts +40 -12
  254. package/src/editing.test.ts +281 -3
  255. package/src/editing.ts +264 -38
  256. package/src/fill-patterns.test.ts +35 -0
  257. package/src/fill-patterns.ts +56 -2
  258. package/src/grid-icons.ts +2 -2
  259. package/src/grid-messages.ts +21 -3
  260. package/src/history.test.ts +196 -0
  261. package/src/history.ts +162 -0
  262. package/src/icon-seam.test.ts +5 -6
  263. package/src/index.ts +169 -12
  264. package/src/keyboard-handlers.coverage.test.ts +126 -6
  265. package/src/keyboard-handlers.ts +91 -30
  266. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  267. package/src/keyboard.test.ts +124 -1
  268. package/src/keyboard.ts +142 -0
  269. package/src/menus.test.ts +26 -0
  270. package/src/menus.ts +10 -3
  271. package/src/merges.test.ts +112 -0
  272. package/src/merges.ts +168 -0
  273. package/src/pivot.grid.test.ts +40 -5
  274. package/src/row-model.ts +146 -0
  275. package/src/row-resize.test.ts +44 -0
  276. package/src/row-resize.ts +17 -0
  277. package/src/scroll-sync.test.ts +42 -1
  278. package/src/scroll-sync.ts +25 -0
  279. package/src/selection.multi-range.test.ts +34 -0
  280. package/src/selection.test.ts +33 -2
  281. package/src/selection.ts +143 -21
  282. package/src/server-block-cache.test.ts +645 -0
  283. package/src/server-block-cache.ts +677 -0
  284. package/src/server-data-source.infinite.test.ts +343 -0
  285. package/src/server-data-source.ts +450 -27
  286. package/src/server.ts +47 -0
  287. package/src/shortcut-registry.test.ts +97 -0
  288. package/src/shortcut-registry.ts +181 -0
  289. package/src/svgrid-wrapper.types.ts +184 -13
  290. package/src/svgrid.behavior.test.ts +135 -0
  291. package/src/svgrid.charting.test.ts +513 -3
  292. package/src/svgrid.context-menu.test.ts +31 -0
  293. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  294. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  295. package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
  296. package/src/validate.test.ts +10 -0
  297. package/src/validate.ts +10 -3
  298. package/src/virtualization/virtualizer.ts +39 -17
  299. package/dist/SvGroupCell.svelte +0 -141
  300. package/dist/SvGroupCell.svelte.d.ts +0 -49
  301. package/dist/SvRowGroupPanel.svelte +0 -186
  302. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  303. package/dist/cdn/GridMenus-CHOMnW-c.js +0 -602
  304. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  305. package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
  306. package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
  307. package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
  308. package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
  309. package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
  310. package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
  311. package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
  312. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  313. package/dist/server-group-model.d.ts +0 -98
  314. package/dist/server-group-model.js +0 -263
  315. package/src/SvGroupCell.svelte +0 -141
  316. package/src/SvRowGroupPanel.svelte +0 -186
  317. package/src/server-group-model.test.ts +0 -294
  318. 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
+ })