@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
@@ -16,9 +16,21 @@
16
16
  * follow-up re-fetch of the current page lands.
17
17
  */
18
18
  import type { GridPredicateExpr } from './filtering/predicate-expr'
19
+ import {
20
+ createBlockCache,
21
+ rowPlaceholderState,
22
+ type BlockCache,
23
+ type BlockState,
24
+ } from './server-block-cache'
25
+ import { toServerFilterColumns, type GridFilterState, type GridRowModel } from './row-model'
19
26
 
27
+ /** Sort clauses in priority order; `id` is the column id. */
20
28
  export type ServerSortModel = Array<{ id: string; desc: boolean }>
21
29
 
30
+ /**
31
+ * What a request carries for filtering: the global search, the per-column
32
+ * operator filters, and the advanced-filter expression.
33
+ */
22
34
  export type ServerFilterModel = {
23
35
  /** Free-text global search. */
24
36
  global?: string
@@ -54,6 +66,11 @@ export type ServerFilterModel = {
54
66
  /** A value column to roll up per group. */
55
67
  export type ServerAggregation = { col: string; fn: 'sum' | 'avg' | 'min' | 'max' | 'count' }
56
68
 
69
+ /**
70
+ * One range of rows as the grid asks for it. A flat request carries the
71
+ * range, the sort and the filter; a grouped request adds `groupBy`,
72
+ * `groupKeys` and `aggregations`; a pivoted one `pivotBy` and `pivotMode`.
73
+ */
57
74
  export type ServerRequest = {
58
75
  /** Zero-based index of the first row wanted (inclusive). */
59
76
  startRow: number
@@ -78,6 +95,33 @@ export type ServerRequest = {
78
95
  groupKeys?: string[]
79
96
  /** Value columns to aggregate per group. */
80
97
  aggregations?: ServerAggregation[]
98
+ /**
99
+ * Server-side pivot: the columns whose distinct values become columns.
100
+ * With `pivotMode` on, a group row carries one value per (pivot key x
101
+ * aggregation) under a field named `<key>_<col>` (the separator is the
102
+ * row model's `pivotFieldSeparator`), and the response lists those
103
+ * fields in `pivotResultFields`. Only the Enterprise row model sets it.
104
+ */
105
+ pivotBy?: string[]
106
+ pivotMode?: boolean
107
+ /**
108
+ * True on a top-level request when the grid wants a grand-total row and
109
+ * does not have one cached. Answer with `ServerResult.grandTotal`. Only
110
+ * the Enterprise server row model sets it.
111
+ */
112
+ needsGrandTotal?: boolean
113
+ /**
114
+ * The group (or tree node) row being expanded, when this request is for
115
+ * its children. Handy for backends that key children off something on
116
+ * the parent rather than off `groupKeys`. Absent at the top level.
117
+ */
118
+ parentRow?: unknown
119
+ /**
120
+ * Whatever the app passed as `context` to its controller, forwarded
121
+ * untouched on every request. Keep it JSON-serialisable: the SvelteKit
122
+ * transport posts the whole request to your endpoint.
123
+ */
124
+ context?: unknown
81
125
  }
82
126
 
83
127
  /**
@@ -101,14 +145,29 @@ export type ServerGroupRow<TData> = {
101
145
  loading: boolean
102
146
  /** Aggregate values keyed by column id, read from the group's response row. */
103
147
  aggregates: Record<string, unknown>
148
+ /**
149
+ * How many rows this group holds, when the backend said (see the row
150
+ * model's `childCount` option). Drawn next to the key by `SvGroupCell`,
151
+ * and used to size the group's scrollbar before its first block lands.
152
+ */
153
+ childCount?: number
154
+ /**
155
+ * `false` when nothing can open beneath this row: the innermost group
156
+ * level under a server-side pivot, whose rows are the result itself.
157
+ * The group cell then draws no expander.
158
+ */
159
+ expandable?: boolean
104
160
  /** The raw response row for this group (key + aggregates), for cell rendering. */
105
161
  data: TData
106
162
  }
107
163
 
164
+ /** A data row in the display list. */
108
165
  export type ServerLeafRow<TData> = {
109
166
  kind: 'leaf'
110
167
  id: string
111
168
  level: number
169
+ /** The group path this leaf sits under. Set by the block-cached row model. */
170
+ route?: string[]
112
171
  data: TData
113
172
  }
114
173
 
@@ -152,17 +211,59 @@ export type ServerSkeletonRow = {
152
211
  level: number
153
212
  }
154
213
 
155
- /** A row in the flattened server-side group/tree display list. */
214
+ /**
215
+ * A grand-total row across the whole result. One per grid, at the top or
216
+ * the bottom, with the fixed id `sv-grand-total` so a transaction can
217
+ * address it.
218
+ */
219
+ export type ServerGrandTotalRow<TData> = {
220
+ kind: 'grandTotal'
221
+ id: 'sv-grand-total'
222
+ level: 0
223
+ aggregates: Record<string, unknown>
224
+ data: TData
225
+ }
226
+
227
+ /**
228
+ * A row whose data has not arrived: `loading` while its block is in
229
+ * flight, `failed` when the fetch rejected. Shared, frozen objects - one
230
+ * per state, not one per row - so a million unloaded rows cost nothing to
231
+ * represent. They also carry the grid's placeholder mark, so
232
+ * `rowPlaceholderState()` recognises them and the grid draws them itself.
233
+ */
234
+ export type ServerPlaceholderRow = {
235
+ readonly kind: 'placeholder'
236
+ readonly state: 'loading' | 'failed'
237
+ }
238
+
239
+ /**
240
+ * Every row a server row model can put on screen. Each carries an `id` and
241
+ * a `level`. The block-cached model adds {@link ServerPlaceholderRow} for
242
+ * rows not yet loaded - see its own `ServerRowModelDisplayRow`.
243
+ */
156
244
  export type ServerDisplayRow<TData> =
157
245
  | ServerGroupRow<TData>
158
246
  | ServerLeafRow<TData>
159
247
  | ServerMoreRow
160
248
  | ServerFooterRow<TData>
161
249
  | ServerSkeletonRow
250
+ | ServerGrandTotalRow<TData>
162
251
 
252
+ /**
253
+ * What `getRows` answers with: the rows for the requested range and the
254
+ * count after filtering, plus the grand total and the pivot fields when
255
+ * asked for.
256
+ */
163
257
  export type ServerResult<TData> = {
164
258
  rows: ReadonlyArray<TData>
165
- /** Total row count after filtering (for the pager). */
259
+ /**
260
+ * Total row count after filtering, for the pager and the scrollbar.
261
+ *
262
+ * Paging needs it. Infinite scrolling does not: send `-1` (or, from a
263
+ * source typed loosely, omit it) when counting is expensive, and the grid
264
+ * discovers the end from the first short block instead. See
265
+ * `mode: 'infinite'` on {@link ServerControllerOptions}.
266
+ */
166
267
  rowCount: number
167
268
  /**
168
269
  * Set `true` ONLY when `filterModel.expression` was applied in full. Leave it
@@ -170,8 +271,50 @@ export type ServerResult<TData> = {
170
271
  * pretending the filter ran. See the contract on `ServerFilterModel.expression`.
171
272
  */
172
273
  appliedExpression?: boolean
274
+ /**
275
+ * The grand-total row, in reply to `ServerRequest.needsGrandTotal`: an
276
+ * object sets it, `null` removes it, and leaving it out keeps whatever the
277
+ * grid already had. Shaped like a group row: the aggregate values live
278
+ * under their column ids.
279
+ */
280
+ grandTotal?: TData | null
281
+ /**
282
+ * Pivot mode: the fields the group rows carry for the pivoted values,
283
+ * e.g. `["2024_amount", "2025_amount"]`, from which the grid builds one
284
+ * column per field under a header group per pivot key. Send the full
285
+ * list on every pivoted response; the grid keeps the union. Every group
286
+ * row carries every listed field, `null` where no rows fall in that
287
+ * cell.
288
+ */
289
+ pivotResultFields?: string[]
290
+ /**
291
+ * Pivot mode, the long way: full column definitions instead of field
292
+ * names, for a backend that wants to name and format them itself. Wins
293
+ * over `pivotResultFields` when both are present.
294
+ */
295
+ pivotResultColumns?: ReadonlyArray<unknown>
173
296
  }
174
297
 
298
+ /**
299
+ * A selection expressed as a rule rather than a list, for `updateWhere`.
300
+ * The flat shape is "these ids" or "everything except these"; the nested
301
+ * shape is the same idea per group, keyed by group key or leaf id, as the
302
+ * Enterprise row model keeps it under `groupSelects: 'descendants'`.
303
+ */
304
+ export type ServerSelectionRule =
305
+ | { selectAll: boolean; toggled: string[] }
306
+ | {
307
+ selectAllChildren: boolean
308
+ toggled: Record<string, { selectAllChildren: boolean; toggled: Record<string, unknown>; group?: boolean }>
309
+ group?: boolean
310
+ /** The group columns the tree's levels are keyed by, outer to inner. */
311
+ groupBy?: string[]
312
+ }
313
+
314
+ /**
315
+ * The contract a backend implements: `getRows`, and optionally the write
316
+ * methods, a bulk edit by rule and `destroy`.
317
+ */
175
318
  export type ServerDataSource<TData> = {
176
319
  getRows(request: ServerRequest): Promise<ServerResult<TData>>
177
320
  /**
@@ -183,8 +326,33 @@ export type ServerDataSource<TData> = {
183
326
  createRow?(input: Partial<TData>): Promise<TData>
184
327
  updateRow?(id: string, patch: Partial<TData>): Promise<TData>
185
328
  deleteRow?(id: string): Promise<void>
329
+ /**
330
+ * Apply one patch to every row a selection RULE names, server-side - the
331
+ * write behind a bulk edit of rows the grid never loaded. `filterModel`
332
+ * scopes the rows exactly as `getRows` does; `selection` is the rule:
333
+ * either "these ids" (`selectAll: false`) or "everything but these"
334
+ * (`selectAll: true`), or the per-group tree the Enterprise row model
335
+ * keeps under `groupSelects: 'descendants'`. Resolve with how many rows
336
+ * changed. Optional: without it, a bulk edit under select-all is refused
337
+ * rather than silently applied to the loaded rows only.
338
+ */
339
+ updateWhere?(
340
+ filterModel: ServerFilterModel,
341
+ patch: Partial<TData>,
342
+ selection: ServerSelectionRule,
343
+ ): Promise<number>
344
+ /**
345
+ * Called once, from the controller's `dispose()`, for a source with
346
+ * something to close: a socket, a subscription, a worker.
347
+ */
348
+ destroy?(): void
186
349
  }
187
350
 
351
+ /**
352
+ * What `createServerDataSource` emits on every change: the rows on hand,
353
+ * the counts, the loading and saving flags, the last error, and the
354
+ * current sort and filter.
355
+ */
188
356
  export type ServerState<TData> = {
189
357
  rows: ReadonlyArray<TData>
190
358
  total: number
@@ -206,13 +374,51 @@ export type ServerState<TData> = {
206
374
  * compiling; the controller always sets it.
207
375
  */
208
376
  expressionUnapplied?: boolean
377
+ /**
378
+ * Rows after filtering, or `null` when the backend has not said and the
379
+ * end has not been found yet. Only ever null in `infinite` mode; `total`
380
+ * carries the best current guess either way.
381
+ */
382
+ rowCount?: number | null
383
+ /** False while the end of an infinite list is still being discovered. */
384
+ lastRowKnown?: boolean
385
+ /** Blocks whose fetch failed, in `infinite` mode. Empty when all is well. */
386
+ failedBlocks?: number[]
209
387
  }
210
388
 
211
- export type ServerController<TData> = {
389
+ /**
390
+ * Everything a controller can do, plus the {@link GridRowModel} surface, so
391
+ * one object can be driven by hand OR handed to `<SvGrid rowModel>` and wire
392
+ * itself up.
393
+ */
394
+ export type ServerController<TData> = GridRowModel<TData> & {
212
395
  /** Re-fetch the current page (e.g. after a mutation). */
213
396
  refresh(): void
397
+ /**
398
+ * The rows on screen, so `infinite` mode knows which blocks to fetch and
399
+ * which to spare from eviction. Wire it to the grid's
400
+ * `onVisibleRangeChange` - or pass the controller as `rowModel` and the
401
+ * grid wires it for you. No-op in `page` mode.
402
+ */
403
+ setViewport(startIndex: number, endIndex: number): void
404
+ /** Re-fetch the blocks that failed. No-op in `page` mode. */
405
+ retryLoads(): void
406
+ /**
407
+ * Throw away every cached block and reload from the current viewport.
408
+ * `refresh()` is the gentler option: it keeps the row count and the scroll
409
+ * position. No-op in `page` mode, where there is only ever one page held.
410
+ */
411
+ purge(): void
412
+ /** Cached blocks and their state, for logging and tests. Empty in `page` mode. */
413
+ getCacheState(): BlockState[]
214
414
  setSort(sortModel: ServerSortModel): void
215
- setFilter(filterModel: ServerFilterModel): void
415
+ /**
416
+ * Replace the filter. Takes the `ServerFilterModel` a request carries, or
417
+ * the payload the grid's own `onFiltersChange` hands you - which is a
418
+ * list of columns rather than a map, and is converted here so that every
419
+ * app does not write the same `Object.fromEntries` by hand.
420
+ */
421
+ setFilter(filterModel: ServerFilterModel | GridFilterState): void
216
422
  setPage(pageIndex: number): void
217
423
  setPageSize(pageSize: number): void
218
424
  /**
@@ -237,10 +443,14 @@ export type ServerController<TData> = {
237
443
  dispose(): void
238
444
  }
239
445
 
446
+ /**
447
+ * Options for `createServerDataSource`: the page size, or the block
448
+ * settings in `infinite` mode, optimistic writes and the change callback.
449
+ */
240
450
  export type ServerControllerOptions<TData> = {
241
451
  pageSize?: number
242
452
  /** Called whenever any of `rows` / `total` / `loading` / page changes. */
243
- onChange: (state: ServerState<TData>) => void
453
+ onChange?: (state: ServerState<TData>) => void
244
454
  /**
245
455
  * Apply `updateRow` / `deleteRow` to the local rows immediately (before the
246
456
  * server confirms) and roll back on error - so edits feel instant and no
@@ -250,8 +460,40 @@ export type ServerControllerOptions<TData> = {
250
460
  optimistic?: boolean
251
461
  /** Resolve a row's stable id, so optimistic update/delete can find it in `rows`. */
252
462
  getRowId?: (row: TData) => string
463
+ /**
464
+ * How rows reach the grid.
465
+ *
466
+ * - `page` (default): one page at a time. `state.rows` is that page, and
467
+ * `setPage` moves between them.
468
+ * - `infinite`: one long scrollable list. `state.rows` spans the whole
469
+ * result, with placeholder rows standing in for blocks nobody has
470
+ * scrolled to yet; blocks load as the viewport reaches them.
471
+ */
472
+ mode?: 'page' | 'infinite'
473
+ /** `infinite` mode: rows per request. Default 100. */
474
+ blockSize?: number
475
+ /**
476
+ * `infinite` mode: keep at most this many loaded blocks, evicting the
477
+ * least recently seen. Unlimited by default.
478
+ */
479
+ maxBlocksInCache?: number
480
+ /** `infinite` mode: requests open at once. Default 2. */
481
+ maxConcurrentRequests?: number
482
+ /** `infinite` mode: wait for the scroll to settle this long before fetching. */
483
+ blockLoadDebounceMs?: number
484
+ /**
485
+ * `infinite` mode: rows to claim before anything has loaded, so there is a
486
+ * scrollbar on first paint. Default 1.
487
+ */
488
+ initialRowCount?: number
253
489
  }
254
490
 
491
+ /**
492
+ * The free server row model over a `ServerDataSource`: one page at a time,
493
+ * or one block-cached list in `infinite` mode, with sort, filter, race
494
+ * safety and writes. The result is a `GridRowModel`, so
495
+ * `<SvGrid rowModel={ctl} />` wires every seam.
496
+ */
255
497
  export function createServerDataSource<TData>(
256
498
  source: ServerDataSource<TData>,
257
499
  options: ServerControllerOptions<TData>,
@@ -270,6 +512,14 @@ export function createServerDataSource<TData>(
270
512
  expressionUnapplied: false,
271
513
  }
272
514
 
515
+ const infinite = options.mode === 'infinite'
516
+ // `onChange` is the one-callback API this controller shipped with;
517
+ // `subscribe` is the many-listener one `GridRowModel` needs. Both fire
518
+ // from `emit`, so a grid driven by `rowModel` and an app reading
519
+ // `onChange` stay in step.
520
+ const subscribers = new Set<() => void>()
521
+ let cache: BlockCache<TData> | null = null
522
+
273
523
  // Monotonic request id: only the latest fetch is allowed to land, so a slow
274
524
  // response for an old sort/filter can't clobber a newer one.
275
525
  let requestSeq = 0
@@ -280,7 +530,88 @@ export function createServerDataSource<TData>(
280
530
 
281
531
  const emit = () => {
282
532
  state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize))
283
- options.onChange({ ...state })
533
+ options.onChange?.({ ...state })
534
+ for (const notify of subscribers) notify()
535
+ }
536
+
537
+ /**
538
+ * Pull the block cache's view of the world into `state` and emit.
539
+ *
540
+ * `total` stays a plain number because the pager and the footer have
541
+ * always read it as one; while the end is undiscovered it holds the
542
+ * current optimistic length, which is exactly what the scrollbar needs.
543
+ * `rowCount` is the honest answer, null and all.
544
+ */
545
+ function emitFromCache(): void {
546
+ if (!cache || disposed) return
547
+ const rows = cache.rows()
548
+ state.rows = rows
549
+ state.rowCount = cache.rowCount()
550
+ state.lastRowKnown = cache.lastRowKnown()
551
+ state.total = cache.rowCount() ?? rows.length
552
+ emit()
553
+ }
554
+
555
+ function buildCache(): BlockCache<TData> {
556
+ return createBlockCache<TData>({
557
+ blockSize: options.blockSize ?? 100,
558
+ maxBlocksInCache: options.maxBlocksInCache,
559
+ maxConcurrentRequests: options.maxConcurrentRequests,
560
+ blockLoadDebounceMs: options.blockLoadDebounceMs,
561
+ initialRowCount: options.initialRowCount,
562
+ fetch: async (startRow, endRow) => {
563
+ const result = await source.getRows({
564
+ startRow,
565
+ endRow,
566
+ // A block is a page of its own size, so a backend that only knows
567
+ // how to page still works unchanged.
568
+ pageIndex: Math.floor(startRow / Math.max(1, endRow - startRow)),
569
+ pageSize: endRow - startRow,
570
+ sortModel: state.sortModel,
571
+ filterModel: state.filterModel,
572
+ groupBy: [],
573
+ groupKeys: [],
574
+ aggregations: [],
575
+ })
576
+ noteExpressionApplied(result)
577
+ return { rows: result.rows, rowCount: result.rowCount }
578
+ },
579
+ onChange: (s) => {
580
+ state.loading = s.loading
581
+ state.failedBlocks = s.failedBlocks
582
+ emitFromCache()
583
+ },
584
+ })
585
+ }
586
+
587
+ /** Reload from scratch: new sort, new filter, or an explicit purge. */
588
+ function resetCache(): void {
589
+ if (disposed) return
590
+ cache?.dispose()
591
+ cache = buildCache()
592
+ cache.setViewport(viewStart, viewEnd)
593
+ emitFromCache()
594
+ }
595
+
596
+ /**
597
+ * An expression was sent but the backend did not acknowledge applying it,
598
+ * so these rows are a SUPERSET of what was asked for. Say so instead of
599
+ * filtering here: filtering one page would turn "3 of 1,000,000 match"
600
+ * into a confident lie and make paging incoherent, since the next page
601
+ * would re-filter a different slice.
602
+ */
603
+ function noteExpressionApplied(result: ServerResult<TData>): void {
604
+ state.expressionUnapplied =
605
+ state.filterModel.expression != null && result.appliedExpression !== true
606
+ if (!state.expressionUnapplied || warnedExpressionUnapplied) return
607
+ warnedExpressionUnapplied = true
608
+ console.warn(
609
+ '[svgrid] The data source was sent filterModel.expression but did not ' +
610
+ 'return `appliedExpression: true`, so the advanced filter is NOT applied ' +
611
+ 'and the rows shown are unfiltered. Apply the whole expression and ' +
612
+ 'acknowledge it, or clear the advanced filter. ' +
613
+ 'See https://svgrid.com/docs/help/server/server-filtering',
614
+ )
284
615
  }
285
616
 
286
617
  async function fetchPage() {
@@ -298,7 +629,8 @@ export function createServerDataSource<TData>(
298
629
  pageSize: state.pageSize,
299
630
  sortModel: state.sortModel,
300
631
  filterModel: state.filterModel,
301
- // Flat mode: no grouping. Group mode lives in createServerGroupModel.
632
+ // Flat mode: no grouping. Server-side grouping and tree data live in
633
+ // `createServerGroupModel` from @svgrid/enterprise, which fills these in.
302
634
  groupBy: [],
303
635
  groupKeys: [],
304
636
  aggregations: [],
@@ -306,23 +638,9 @@ export function createServerDataSource<TData>(
306
638
  if (disposed || id !== requestSeq) return // stale
307
639
  state.rows = result.rows
308
640
  state.total = result.rowCount
309
- // An expression was sent but the backend did not acknowledge applying it,
310
- // so these rows are a SUPERSET of what was asked for. Say so instead of
311
- // filtering the loaded page here: filtering one page would turn
312
- // "3 of 1,000,000 match" into a confident lie and make paging incoherent,
313
- // since page 2 would re-filter a different slice.
314
- state.expressionUnapplied =
315
- state.filterModel.expression != null && result.appliedExpression !== true
316
- if (state.expressionUnapplied && !warnedExpressionUnapplied) {
317
- warnedExpressionUnapplied = true
318
- console.warn(
319
- '[svgrid] The data source was sent filterModel.expression but did not ' +
320
- 'return `appliedExpression: true`, so the advanced filter is NOT applied ' +
321
- 'and the rows shown are unfiltered. Apply the whole expression and ' +
322
- 'acknowledge it, or clear the advanced filter. ' +
323
- 'See https://svgrid.com/docs/help/server/server-filtering',
324
- )
325
- }
641
+ state.rowCount = result.rowCount
642
+ state.lastRowKnown = true
643
+ noteExpressionApplied(result)
326
644
  state.loading = false
327
645
  emit()
328
646
  } catch (err) {
@@ -351,7 +669,10 @@ export function createServerDataSource<TData>(
351
669
  return (async () => {
352
670
  try {
353
671
  const result = await thunk()
354
- await fetchPage()
672
+ // Page mode re-reads the page. Infinite mode re-reads the blocks it
673
+ // holds, in place: the list keeps its length and its scroll.
674
+ if (infinite) cache?.refresh()
675
+ else await fetchPage()
355
676
  return result
356
677
  } finally {
357
678
  state.saving = false
@@ -363,6 +684,11 @@ export function createServerDataSource<TData>(
363
684
  const optimistic = !!options.optimistic && !!options.getRowId
364
685
  const getRowId = options.getRowId
365
686
 
687
+ // The last range the grid reported, so a sort / filter / purge can
688
+ // re-request what the user is actually looking at rather than row 0.
689
+ let viewStart = 0
690
+ let viewEnd = 0
691
+
366
692
  // Optimistic update: patch the local row, reconcile with the server result,
367
693
  // roll back on error. Falls back to the plain refresh path when the row
368
694
  // isn't on the current page (nothing local to update).
@@ -372,6 +698,27 @@ export function createServerDataSource<TData>(
372
698
  fn: (id: string, patch: Partial<TData>) => Promise<TData>,
373
699
  ): Promise<TData> {
374
700
  if (disposed) throw new Error('createServerDataSource: controller is disposed')
701
+ if (infinite && cache) {
702
+ // The cache owns the rows here; patch it, not a copy the next block
703
+ // to land would overwrite.
704
+ const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)
705
+ if (at < 0) return mutate('updateRow', () => fn(id, patch))
706
+ const prev = cache.getRow(at) as TData
707
+ cache.patch(at, { ...prev, ...patch })
708
+ state.saving = true
709
+ emit()
710
+ try {
711
+ const result = await fn(id, patch)
712
+ cache.patch(at, result)
713
+ return result
714
+ } catch (err) {
715
+ cache.patch(at, prev)
716
+ throw err
717
+ } finally {
718
+ state.saving = false
719
+ emit()
720
+ }
721
+ }
375
722
  const prevRows = state.rows
376
723
  const idx = prevRows.findIndex((r) => getRowId!(r) === id)
377
724
  if (idx < 0) return mutate('updateRow', () => fn(id, patch))
@@ -398,6 +745,24 @@ export function createServerDataSource<TData>(
398
745
  fn: (id: string) => Promise<void>,
399
746
  ): Promise<void> {
400
747
  if (disposed) throw new Error('createServerDataSource: controller is disposed')
748
+ if (infinite && cache) {
749
+ const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId!(r) === id)
750
+ if (at < 0) return mutate('deleteRow', () => fn(id))
751
+ const prev = cache.getRow(at) as TData
752
+ cache.remove(at, 1)
753
+ state.saving = true
754
+ emit()
755
+ try {
756
+ await fn(id)
757
+ } catch (err) {
758
+ cache.insert(at, [prev])
759
+ throw err
760
+ } finally {
761
+ state.saving = false
762
+ emit()
763
+ }
764
+ return
765
+ }
401
766
  const prevRows = state.rows
402
767
  const prevTotal = state.total
403
768
  const next = prevRows.filter((r) => getRowId!(r) !== id)
@@ -419,8 +784,53 @@ export function createServerDataSource<TData>(
419
784
  }
420
785
  }
421
786
 
787
+ // In infinite mode the first blocks are requested as soon as the grid
788
+ // reports a viewport; `refresh()` is what arms the cache before that.
789
+ if (infinite) cache = buildCache()
790
+
422
791
  return {
423
- refresh: fetchPage,
792
+ refresh: () => {
793
+ if (!infinite) return void fetchPage()
794
+ // Keep the count and the scroll position, re-request what is held.
795
+ cache?.refresh()
796
+ },
797
+ setViewport(startIndex, endIndex) {
798
+ viewStart = startIndex
799
+ viewEnd = endIndex
800
+ cache?.setViewport(startIndex, endIndex)
801
+ },
802
+ retryLoads: () => cache?.retryFailed(),
803
+ purge: () => cache?.purge(),
804
+ getCacheState: () => cache?.getCacheState() ?? [],
805
+
806
+ // --- GridRowModel -----------------------------------------------
807
+ subscribe(onChange) {
808
+ subscribers.add(onChange)
809
+ return () => {
810
+ subscribers.delete(onChange)
811
+ }
812
+ },
813
+ getRows: () => state.rows,
814
+ isLoading: () => state.loading,
815
+ getRowId: options.getRowId ? (row: TData) => options.getRowId!(row) : undefined,
816
+ // Only infinite mode has unloaded rows to stand in for.
817
+ rowPlaceholder: infinite ? (row: TData) => rowPlaceholderState(row) : undefined,
818
+ retryRow: infinite ? () => cache?.retryFailed() : undefined,
819
+ /**
820
+ * A getter, not a snapshot: the grid re-reads this after every change
821
+ * notification, and a frozen object would leave the pager on page 1
822
+ * forever. Absent in infinite mode, where there are no pages.
823
+ */
824
+ get pagination() {
825
+ if (infinite) return undefined
826
+ return {
827
+ pageIndex: state.pageIndex,
828
+ pageSize: state.pageSize,
829
+ rowCount: state.total,
830
+ setPage: (pageIndex: number) => this.setPage(pageIndex),
831
+ setPageSize: (pageSize: number) => this.setPageSize(pageSize),
832
+ }
833
+ },
424
834
  createRow: (input) =>
425
835
  mutate('createRow', source.createRow ? () => source.createRow!(input) : null),
426
836
  updateRow: (id, patch) => {
@@ -436,14 +846,24 @@ export function createServerDataSource<TData>(
436
846
  setSort(sortModel) {
437
847
  state.sortModel = sortModel
438
848
  state.pageIndex = 0
849
+ if (infinite) return resetCache()
439
850
  void fetchPage()
440
851
  },
441
852
  setFilter(filterModel) {
442
- state.filterModel = filterModel
853
+ state.filterModel = Array.isArray(filterModel.columns)
854
+ ? {
855
+ global: filterModel.global,
856
+ columns: toServerFilterColumns(filterModel as GridFilterState),
857
+ }
858
+ : (filterModel as ServerFilterModel)
443
859
  state.pageIndex = 0
860
+ if (infinite) return resetCache()
444
861
  void fetchPage()
445
862
  },
446
863
  setPage(pageIndex) {
864
+ // There are no pages to move between when the whole result is one
865
+ // scrollable list; the grid scrolls instead.
866
+ if (infinite) return
447
867
  const clamped = Math.max(0, pageIndex)
448
868
  if (clamped === state.pageIndex) return
449
869
  state.pageIndex = clamped
@@ -452,11 +872,14 @@ export function createServerDataSource<TData>(
452
872
  setPageSize(pageSize) {
453
873
  state.pageSize = Math.max(1, pageSize)
454
874
  state.pageIndex = 0
875
+ if (infinite) return
455
876
  void fetchPage()
456
877
  },
457
878
  getState: () => ({ ...state }),
458
879
  dispose() {
459
880
  disposed = true
881
+ cache?.dispose()
882
+ cache = null
460
883
  // An in-flight fetch's resolution short-circuits on `disposed`, so it
461
884
  // never clears `loading`. Clear it here (and emit) so a disposed
462
885
  // controller doesn't report a permanent loading state.