@svgrid/grid 2.1.0 → 2.1.1

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 (64) hide show
  1. package/dist/FlexRender.svelte +96 -96
  2. package/dist/GridFooter.svelte +178 -178
  3. package/dist/SvGrid.css +2377 -2377
  4. package/dist/SvGrid.svelte +2764 -2764
  5. package/dist/SvGridBoard.svelte +2281 -2281
  6. package/dist/SvGridDropdown.svelte +666 -666
  7. package/dist/cdn/svgrid.js +2 -2
  8. package/dist/cdn/svgrid.svelte-external.js +2 -2
  9. package/package.json +92 -92
  10. package/src/FlexRender.svelte +96 -96
  11. package/src/GridFooter.svelte +178 -178
  12. package/src/SvGrid.controller.svelte.ts +2553 -2553
  13. package/src/SvGrid.css +2377 -2377
  14. package/src/SvGrid.svelte +2764 -2764
  15. package/src/SvGrid.types.ts +944 -944
  16. package/src/SvGridBoard.svelte +2281 -2281
  17. package/src/SvGridDropdown.svelte +666 -666
  18. package/src/a11y.contract.test.ts +49 -49
  19. package/src/a11y.test.ts +59 -59
  20. package/src/a11y.ts +61 -61
  21. package/src/build-api.ts +798 -798
  22. package/src/cell-formatting.ts +169 -169
  23. package/src/cell-render.ts +469 -469
  24. package/src/collaboration.test.ts +104 -104
  25. package/src/collaboration.ts +167 -167
  26. package/src/core.performance.test.ts +30 -30
  27. package/src/core.ts +1111 -1111
  28. package/src/createGrid.svelte.ts +42 -42
  29. package/src/createGrid.test.ts +10 -10
  30. package/src/createGridState.svelte.ts +17 -17
  31. package/src/editing.test.ts +859 -859
  32. package/src/editing.ts +675 -675
  33. package/src/export-data-api.test.ts +126 -126
  34. package/src/export-format.test.ts +107 -107
  35. package/src/export-format.ts +598 -598
  36. package/src/flex-render.ts +3 -3
  37. package/src/index.ts +463 -463
  38. package/src/keyboard.test.ts +59 -59
  39. package/src/keyboard.ts +97 -97
  40. package/src/menus.ts +582 -582
  41. package/src/merge-objects.ts +48 -48
  42. package/src/render-component.ts +28 -28
  43. package/src/selection.test.ts +754 -754
  44. package/src/selection.ts +600 -600
  45. package/src/server-data-source.test.ts +289 -289
  46. package/src/server-data-source.ts +413 -413
  47. package/src/sparkline.test.ts +68 -68
  48. package/src/sparkline.ts +169 -169
  49. package/src/spreadsheet.test.ts +489 -489
  50. package/src/spreadsheet.ts +304 -304
  51. package/src/static-functions.ts +11 -11
  52. package/src/subscribe.ts +38 -38
  53. package/src/svgrid-wrapper.types.ts +439 -439
  54. package/src/svgrid.behavior.test.ts +706 -706
  55. package/src/svgrid.features.test.ts +157 -157
  56. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  57. package/src/svgrid.wrapper.test.ts +40 -40
  58. package/src/themes/index.ts +159 -159
  59. package/src/virtualization/column-virtualizer.test.ts +27 -27
  60. package/src/virtualization/column-virtualizer.ts +30 -30
  61. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  62. package/src/virtualization/types.ts +30 -30
  63. package/src/virtualization/virtualizer.test.ts +47 -47
  64. package/src/virtualization/virtualizer.ts +296 -296
@@ -1,289 +1,289 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
- import { createServerDataSource, type ServerDataSource } from './server-data-source'
3
-
4
- type Row = { id: number }
5
-
6
- function makeSource(total = 250, delay = 0): ServerDataSource<Row> & { calls: number } {
7
- const all: Row[] = Array.from({ length: total }, (_, i) => ({ id: i }))
8
- return {
9
- calls: 0,
10
- async getRows(req) {
11
- this.calls += 1
12
- if (delay) await new Promise((r) => setTimeout(r, delay))
13
- // server applies sort: reverse when desc on 'id'
14
- let rows = all
15
- if (req.sortModel[0]?.id === 'id' && req.sortModel[0]?.desc) rows = [...all].reverse()
16
- return { rows: rows.slice(req.startRow, req.endRow), rowCount: total }
17
- },
18
- }
19
- }
20
-
21
- const flush = () => new Promise((r) => setTimeout(r, 0))
22
-
23
- describe('createServerDataSource', () => {
24
- it('fetches a page and reports total + pageCount', async () => {
25
- const src = makeSource(250)
26
- const states: any[] = []
27
- const ctl = createServerDataSource(src, { pageSize: 50, onChange: (s) => states.push(s) })
28
- ctl.refresh()
29
- await flush()
30
- const last = ctl.getState()
31
- expect(last.rows).toHaveLength(50)
32
- expect(last.total).toBe(250)
33
- expect(last.pageCount).toBe(5)
34
- expect(last.loading).toBe(false)
35
- // emitted a loading=true then loading=false
36
- expect(states.some((s) => s.loading)).toBe(true)
37
- })
38
-
39
- it('dispose() during an in-flight fetch clears the loading state (#83)', async () => {
40
- const src = makeSource(250, 20) // 20ms delay keeps the fetch in-flight
41
- const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
42
- ctl.refresh()
43
- expect(ctl.getState().loading).toBe(true)
44
- ctl.dispose()
45
- expect(ctl.getState().loading).toBe(false)
46
- await new Promise((r) => setTimeout(r, 30)) // let the fetch settle
47
- expect(ctl.getState().loading).toBe(false) // stays cleared, not stuck
48
- })
49
-
50
- it('setPage fetches the right slice', async () => {
51
- const src = makeSource(250)
52
- const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
53
- ctl.setPage(2)
54
- await flush()
55
- expect(ctl.getState().rows[0]).toEqual({ id: 100 })
56
- })
57
-
58
- it('setSort resets to page 0 and re-fetches', async () => {
59
- const src = makeSource(250)
60
- const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
61
- ctl.setPage(2)
62
- await flush()
63
- ctl.setSort([{ id: 'id', desc: true }])
64
- await flush()
65
- const s = ctl.getState()
66
- expect(s.pageIndex).toBe(0)
67
- expect(s.rows[0]).toEqual({ id: 249 }) // reversed
68
- })
69
-
70
- it('ignores a stale response when a newer request supersedes it', async () => {
71
- // slow source: first call (page 0) resolves AFTER the second (page 1)
72
- const all = Array.from({ length: 100 }, (_, i) => ({ id: i }))
73
- let call = 0
74
- const src: ServerDataSource<Row> = {
75
- getRows: vi.fn(async (req) => {
76
- call += 1
77
- const delay = call === 1 ? 40 : 5 // first call is slow
78
- await new Promise((r) => setTimeout(r, delay))
79
- return { rows: all.slice(req.startRow, req.endRow), rowCount: 100 }
80
- }),
81
- }
82
- const ctl = createServerDataSource(src, { pageSize: 10, onChange: () => {} })
83
- ctl.refresh() // page 0 (slow)
84
- ctl.setPage(3) // page 30 (fast) - should win
85
- await new Promise((r) => setTimeout(r, 80))
86
- expect(ctl.getState().rows[0]).toEqual({ id: 30 })
87
- })
88
-
89
- it('dispose stops further updates', async () => {
90
- const src = makeSource(50, 20)
91
- const onChange = vi.fn()
92
- const ctl = createServerDataSource(src, { pageSize: 10, onChange })
93
- ctl.refresh()
94
- ctl.dispose()
95
- await new Promise((r) => setTimeout(r, 40))
96
- // only the synchronous loading=true emit happened before dispose
97
- expect(ctl.getState().rows).toHaveLength(0)
98
- })
99
- })
100
-
101
- describe('createServerDataSource mutations', () => {
102
- type URow = { id: number; name: string }
103
-
104
- function writable() {
105
- let store: URow[] = [
106
- { id: 0, name: 'a' },
107
- { id: 1, name: 'b' },
108
- { id: 2, name: 'c' },
109
- ]
110
- return {
111
- getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
112
- rows: store.slice(req.startRow, req.endRow),
113
- rowCount: store.length,
114
- })),
115
- createRow: vi.fn(async (input: Partial<URow>) => {
116
- const row: URow = { id: store.length, name: input.name ?? '' }
117
- store = [...store, row]
118
- return row
119
- }),
120
- updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
121
- store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
122
- return store.find((r) => String(r.id) === id)!
123
- }),
124
- deleteRow: vi.fn(async (id: string) => {
125
- store = store.filter((r) => String(r.id) !== id)
126
- }),
127
- } as unknown as ServerDataSource<URow> & {
128
- getRows: ReturnType<typeof vi.fn>
129
- createRow: ReturnType<typeof vi.fn>
130
- updateRow: ReturnType<typeof vi.fn>
131
- deleteRow: ReturnType<typeof vi.fn>
132
- }
133
- }
134
-
135
- it('createRow writes through and refreshes the current page', async () => {
136
- const src = writable()
137
- const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
138
- ctl.refresh()
139
- await flush()
140
- const created = await ctl.createRow({ name: 'z' })
141
- expect(created).toEqual({ id: 3, name: 'z' })
142
- expect(src.createRow).toHaveBeenCalledWith({ name: 'z' })
143
- // the follow-up refresh re-fetched, so total + rows reflect the write
144
- expect(ctl.getState().total).toBe(4)
145
- expect(ctl.getState().rows.some((r) => r.name === 'z')).toBe(true)
146
- })
147
-
148
- it('toggles `saving` around a mutation', async () => {
149
- const src = writable()
150
- const states: Array<{ saving: boolean }> = []
151
- const ctl = createServerDataSource(src, { pageSize: 50, onChange: (s) => states.push(s) })
152
- const p = ctl.createRow({ name: 'x' })
153
- expect(ctl.getState().saving).toBe(true) // synchronous emit before the await
154
- await p
155
- expect(ctl.getState().saving).toBe(false)
156
- expect(states.some((s) => s.saving)).toBe(true)
157
- })
158
-
159
- it('updateRow and deleteRow call through and refresh', async () => {
160
- const src = writable()
161
- const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
162
- ctl.refresh()
163
- await flush()
164
- const updated = await ctl.updateRow('1', { name: 'B!' })
165
- expect(updated).toEqual({ id: 1, name: 'B!' })
166
- expect(src.updateRow).toHaveBeenCalledWith('1', { name: 'B!' })
167
- expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
168
-
169
- await ctl.deleteRow('0')
170
- expect(src.deleteRow).toHaveBeenCalledWith('0')
171
- expect(ctl.getState().total).toBe(2)
172
- expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
173
- })
174
-
175
- it('rejects when the source has no matching writer', async () => {
176
- const readonly: ServerDataSource<URow> = {
177
- getRows: async () => ({ rows: [], rowCount: 0 }),
178
- }
179
- const ctl = createServerDataSource(readonly, { onChange: () => {} })
180
- await expect(ctl.createRow({ name: 'x' })).rejects.toThrow(/does not implement createRow/)
181
- await expect(ctl.updateRow('1', {})).rejects.toThrow(/does not implement updateRow/)
182
- await expect(ctl.deleteRow('1')).rejects.toThrow(/does not implement deleteRow/)
183
- })
184
-
185
- it('rejects mutations after dispose, without calling the source', async () => {
186
- const src = writable()
187
- const ctl = createServerDataSource(src, { onChange: () => {} })
188
- ctl.dispose()
189
- await expect(ctl.deleteRow('0')).rejects.toThrow(/disposed/)
190
- expect(src.deleteRow).not.toHaveBeenCalled()
191
- })
192
-
193
- it('leaves read state untouched when a mutation fails', async () => {
194
- const src = writable()
195
- src.createRow.mockRejectedValueOnce(new Error('boom'))
196
- const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
197
- ctl.refresh()
198
- await flush()
199
- await expect(ctl.createRow({ name: 'x' })).rejects.toThrow('boom')
200
- expect(ctl.getState().saving).toBe(false)
201
- expect(ctl.getState().total).toBe(3) // unchanged; no refresh on failure
202
- })
203
- })
204
-
205
- describe('createServerDataSource optimistic mutations', () => {
206
- type URow = { id: number; name: string }
207
- const opts = (onChange: (s: any) => void = () => {}) => ({
208
- pageSize: 50,
209
- optimistic: true,
210
- getRowId: (r: URow) => String(r.id),
211
- onChange,
212
- })
213
-
214
- function writable() {
215
- let store: URow[] = [
216
- { id: 0, name: 'a' },
217
- { id: 1, name: 'b' },
218
- { id: 2, name: 'c' },
219
- ]
220
- return {
221
- getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
222
- rows: store.slice(req.startRow, req.endRow),
223
- rowCount: store.length,
224
- })),
225
- updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
226
- store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
227
- return store.find((r) => String(r.id) === id)!
228
- }),
229
- deleteRow: vi.fn(async (id: string) => {
230
- store = store.filter((r) => String(r.id) !== id)
231
- }),
232
- } as any
233
- }
234
-
235
- it('applies an update to local rows immediately, before the server resolves', async () => {
236
- const src = writable()
237
- src.updateRow.mockImplementationOnce(
238
- (id: string, patch: any) => new Promise((r) => setTimeout(() => r({ id: Number(id), ...patch }), 30)),
239
- )
240
- const ctl = createServerDataSource(src, opts())
241
- ctl.refresh()
242
- await flush()
243
- const p = ctl.updateRow('1', { name: 'B!' })
244
- // synchronously reflected, no refetch
245
- expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
246
- expect(ctl.getState().saving).toBe(true)
247
- await p
248
- expect(ctl.getState().saving).toBe(false)
249
- // getRows only called once (the initial refresh) - no refetch for the update
250
- expect(src.getRows).toHaveBeenCalledTimes(1)
251
- })
252
-
253
- it('rolls an update back on error', async () => {
254
- const src = writable()
255
- src.updateRow.mockRejectedValueOnce(new Error('nope'))
256
- const ctl = createServerDataSource(src, opts())
257
- ctl.refresh()
258
- await flush()
259
- await expect(ctl.updateRow('1', { name: 'B!' })).rejects.toThrow('nope')
260
- expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('b') // restored
261
- })
262
-
263
- it('removes a row immediately on delete and decrements total, restoring on error', async () => {
264
- const src = writable()
265
- const ctl = createServerDataSource(src, opts())
266
- ctl.refresh()
267
- await flush()
268
-
269
- const p = ctl.deleteRow('0')
270
- expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
271
- expect(ctl.getState().total).toBe(2)
272
- await p
273
-
274
- src.deleteRow.mockRejectedValueOnce(new Error('locked'))
275
- await expect(ctl.deleteRow('1')).rejects.toThrow('locked')
276
- expect(ctl.getState().rows.some((r) => r.id === 1)).toBe(true) // restored
277
- expect(ctl.getState().total).toBe(2)
278
- })
279
-
280
- it('falls back to a refetch when the row is not on the current page', async () => {
281
- const src = writable()
282
- const ctl = createServerDataSource(src, opts())
283
- ctl.refresh()
284
- await flush()
285
- await ctl.updateRow('999', { name: 'ghost' }) // not present locally
286
- // fell back to mutate() which refreshes: getRows called again
287
- expect(src.getRows).toHaveBeenCalledTimes(2)
288
- })
289
- })
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createServerDataSource, type ServerDataSource } from './server-data-source'
3
+
4
+ type Row = { id: number }
5
+
6
+ function makeSource(total = 250, delay = 0): ServerDataSource<Row> & { calls: number } {
7
+ const all: Row[] = Array.from({ length: total }, (_, i) => ({ id: i }))
8
+ return {
9
+ calls: 0,
10
+ async getRows(req) {
11
+ this.calls += 1
12
+ if (delay) await new Promise((r) => setTimeout(r, delay))
13
+ // server applies sort: reverse when desc on 'id'
14
+ let rows = all
15
+ if (req.sortModel[0]?.id === 'id' && req.sortModel[0]?.desc) rows = [...all].reverse()
16
+ return { rows: rows.slice(req.startRow, req.endRow), rowCount: total }
17
+ },
18
+ }
19
+ }
20
+
21
+ const flush = () => new Promise((r) => setTimeout(r, 0))
22
+
23
+ describe('createServerDataSource', () => {
24
+ it('fetches a page and reports total + pageCount', async () => {
25
+ const src = makeSource(250)
26
+ const states: any[] = []
27
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: (s) => states.push(s) })
28
+ ctl.refresh()
29
+ await flush()
30
+ const last = ctl.getState()
31
+ expect(last.rows).toHaveLength(50)
32
+ expect(last.total).toBe(250)
33
+ expect(last.pageCount).toBe(5)
34
+ expect(last.loading).toBe(false)
35
+ // emitted a loading=true then loading=false
36
+ expect(states.some((s) => s.loading)).toBe(true)
37
+ })
38
+
39
+ it('dispose() during an in-flight fetch clears the loading state (#83)', async () => {
40
+ const src = makeSource(250, 20) // 20ms delay keeps the fetch in-flight
41
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
42
+ ctl.refresh()
43
+ expect(ctl.getState().loading).toBe(true)
44
+ ctl.dispose()
45
+ expect(ctl.getState().loading).toBe(false)
46
+ await new Promise((r) => setTimeout(r, 30)) // let the fetch settle
47
+ expect(ctl.getState().loading).toBe(false) // stays cleared, not stuck
48
+ })
49
+
50
+ it('setPage fetches the right slice', async () => {
51
+ const src = makeSource(250)
52
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
53
+ ctl.setPage(2)
54
+ await flush()
55
+ expect(ctl.getState().rows[0]).toEqual({ id: 100 })
56
+ })
57
+
58
+ it('setSort resets to page 0 and re-fetches', async () => {
59
+ const src = makeSource(250)
60
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
61
+ ctl.setPage(2)
62
+ await flush()
63
+ ctl.setSort([{ id: 'id', desc: true }])
64
+ await flush()
65
+ const s = ctl.getState()
66
+ expect(s.pageIndex).toBe(0)
67
+ expect(s.rows[0]).toEqual({ id: 249 }) // reversed
68
+ })
69
+
70
+ it('ignores a stale response when a newer request supersedes it', async () => {
71
+ // slow source: first call (page 0) resolves AFTER the second (page 1)
72
+ const all = Array.from({ length: 100 }, (_, i) => ({ id: i }))
73
+ let call = 0
74
+ const src: ServerDataSource<Row> = {
75
+ getRows: vi.fn(async (req) => {
76
+ call += 1
77
+ const delay = call === 1 ? 40 : 5 // first call is slow
78
+ await new Promise((r) => setTimeout(r, delay))
79
+ return { rows: all.slice(req.startRow, req.endRow), rowCount: 100 }
80
+ }),
81
+ }
82
+ const ctl = createServerDataSource(src, { pageSize: 10, onChange: () => {} })
83
+ ctl.refresh() // page 0 (slow)
84
+ ctl.setPage(3) // page 30 (fast) - should win
85
+ await new Promise((r) => setTimeout(r, 80))
86
+ expect(ctl.getState().rows[0]).toEqual({ id: 30 })
87
+ })
88
+
89
+ it('dispose stops further updates', async () => {
90
+ const src = makeSource(50, 20)
91
+ const onChange = vi.fn()
92
+ const ctl = createServerDataSource(src, { pageSize: 10, onChange })
93
+ ctl.refresh()
94
+ ctl.dispose()
95
+ await new Promise((r) => setTimeout(r, 40))
96
+ // only the synchronous loading=true emit happened before dispose
97
+ expect(ctl.getState().rows).toHaveLength(0)
98
+ })
99
+ })
100
+
101
+ describe('createServerDataSource mutations', () => {
102
+ type URow = { id: number; name: string }
103
+
104
+ function writable() {
105
+ let store: URow[] = [
106
+ { id: 0, name: 'a' },
107
+ { id: 1, name: 'b' },
108
+ { id: 2, name: 'c' },
109
+ ]
110
+ return {
111
+ getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
112
+ rows: store.slice(req.startRow, req.endRow),
113
+ rowCount: store.length,
114
+ })),
115
+ createRow: vi.fn(async (input: Partial<URow>) => {
116
+ const row: URow = { id: store.length, name: input.name ?? '' }
117
+ store = [...store, row]
118
+ return row
119
+ }),
120
+ updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
121
+ store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
122
+ return store.find((r) => String(r.id) === id)!
123
+ }),
124
+ deleteRow: vi.fn(async (id: string) => {
125
+ store = store.filter((r) => String(r.id) !== id)
126
+ }),
127
+ } as unknown as ServerDataSource<URow> & {
128
+ getRows: ReturnType<typeof vi.fn>
129
+ createRow: ReturnType<typeof vi.fn>
130
+ updateRow: ReturnType<typeof vi.fn>
131
+ deleteRow: ReturnType<typeof vi.fn>
132
+ }
133
+ }
134
+
135
+ it('createRow writes through and refreshes the current page', async () => {
136
+ const src = writable()
137
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
138
+ ctl.refresh()
139
+ await flush()
140
+ const created = await ctl.createRow({ name: 'z' })
141
+ expect(created).toEqual({ id: 3, name: 'z' })
142
+ expect(src.createRow).toHaveBeenCalledWith({ name: 'z' })
143
+ // the follow-up refresh re-fetched, so total + rows reflect the write
144
+ expect(ctl.getState().total).toBe(4)
145
+ expect(ctl.getState().rows.some((r) => r.name === 'z')).toBe(true)
146
+ })
147
+
148
+ it('toggles `saving` around a mutation', async () => {
149
+ const src = writable()
150
+ const states: Array<{ saving: boolean }> = []
151
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: (s) => states.push(s) })
152
+ const p = ctl.createRow({ name: 'x' })
153
+ expect(ctl.getState().saving).toBe(true) // synchronous emit before the await
154
+ await p
155
+ expect(ctl.getState().saving).toBe(false)
156
+ expect(states.some((s) => s.saving)).toBe(true)
157
+ })
158
+
159
+ it('updateRow and deleteRow call through and refresh', async () => {
160
+ const src = writable()
161
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
162
+ ctl.refresh()
163
+ await flush()
164
+ const updated = await ctl.updateRow('1', { name: 'B!' })
165
+ expect(updated).toEqual({ id: 1, name: 'B!' })
166
+ expect(src.updateRow).toHaveBeenCalledWith('1', { name: 'B!' })
167
+ expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
168
+
169
+ await ctl.deleteRow('0')
170
+ expect(src.deleteRow).toHaveBeenCalledWith('0')
171
+ expect(ctl.getState().total).toBe(2)
172
+ expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
173
+ })
174
+
175
+ it('rejects when the source has no matching writer', async () => {
176
+ const readonly: ServerDataSource<URow> = {
177
+ getRows: async () => ({ rows: [], rowCount: 0 }),
178
+ }
179
+ const ctl = createServerDataSource(readonly, { onChange: () => {} })
180
+ await expect(ctl.createRow({ name: 'x' })).rejects.toThrow(/does not implement createRow/)
181
+ await expect(ctl.updateRow('1', {})).rejects.toThrow(/does not implement updateRow/)
182
+ await expect(ctl.deleteRow('1')).rejects.toThrow(/does not implement deleteRow/)
183
+ })
184
+
185
+ it('rejects mutations after dispose, without calling the source', async () => {
186
+ const src = writable()
187
+ const ctl = createServerDataSource(src, { onChange: () => {} })
188
+ ctl.dispose()
189
+ await expect(ctl.deleteRow('0')).rejects.toThrow(/disposed/)
190
+ expect(src.deleteRow).not.toHaveBeenCalled()
191
+ })
192
+
193
+ it('leaves read state untouched when a mutation fails', async () => {
194
+ const src = writable()
195
+ src.createRow.mockRejectedValueOnce(new Error('boom'))
196
+ const ctl = createServerDataSource(src, { pageSize: 50, onChange: () => {} })
197
+ ctl.refresh()
198
+ await flush()
199
+ await expect(ctl.createRow({ name: 'x' })).rejects.toThrow('boom')
200
+ expect(ctl.getState().saving).toBe(false)
201
+ expect(ctl.getState().total).toBe(3) // unchanged; no refresh on failure
202
+ })
203
+ })
204
+
205
+ describe('createServerDataSource optimistic mutations', () => {
206
+ type URow = { id: number; name: string }
207
+ const opts = (onChange: (s: any) => void = () => {}) => ({
208
+ pageSize: 50,
209
+ optimistic: true,
210
+ getRowId: (r: URow) => String(r.id),
211
+ onChange,
212
+ })
213
+
214
+ function writable() {
215
+ let store: URow[] = [
216
+ { id: 0, name: 'a' },
217
+ { id: 1, name: 'b' },
218
+ { id: 2, name: 'c' },
219
+ ]
220
+ return {
221
+ getRows: vi.fn(async (req: { startRow: number; endRow: number }) => ({
222
+ rows: store.slice(req.startRow, req.endRow),
223
+ rowCount: store.length,
224
+ })),
225
+ updateRow: vi.fn(async (id: string, patch: Partial<URow>) => {
226
+ store = store.map((r) => (String(r.id) === id ? { ...r, ...patch } : r))
227
+ return store.find((r) => String(r.id) === id)!
228
+ }),
229
+ deleteRow: vi.fn(async (id: string) => {
230
+ store = store.filter((r) => String(r.id) !== id)
231
+ }),
232
+ } as any
233
+ }
234
+
235
+ it('applies an update to local rows immediately, before the server resolves', async () => {
236
+ const src = writable()
237
+ src.updateRow.mockImplementationOnce(
238
+ (id: string, patch: any) => new Promise((r) => setTimeout(() => r({ id: Number(id), ...patch }), 30)),
239
+ )
240
+ const ctl = createServerDataSource(src, opts())
241
+ ctl.refresh()
242
+ await flush()
243
+ const p = ctl.updateRow('1', { name: 'B!' })
244
+ // synchronously reflected, no refetch
245
+ expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('B!')
246
+ expect(ctl.getState().saving).toBe(true)
247
+ await p
248
+ expect(ctl.getState().saving).toBe(false)
249
+ // getRows only called once (the initial refresh) - no refetch for the update
250
+ expect(src.getRows).toHaveBeenCalledTimes(1)
251
+ })
252
+
253
+ it('rolls an update back on error', async () => {
254
+ const src = writable()
255
+ src.updateRow.mockRejectedValueOnce(new Error('nope'))
256
+ const ctl = createServerDataSource(src, opts())
257
+ ctl.refresh()
258
+ await flush()
259
+ await expect(ctl.updateRow('1', { name: 'B!' })).rejects.toThrow('nope')
260
+ expect(ctl.getState().rows.find((r) => r.id === 1)?.name).toBe('b') // restored
261
+ })
262
+
263
+ it('removes a row immediately on delete and decrements total, restoring on error', async () => {
264
+ const src = writable()
265
+ const ctl = createServerDataSource(src, opts())
266
+ ctl.refresh()
267
+ await flush()
268
+
269
+ const p = ctl.deleteRow('0')
270
+ expect(ctl.getState().rows.some((r) => r.id === 0)).toBe(false)
271
+ expect(ctl.getState().total).toBe(2)
272
+ await p
273
+
274
+ src.deleteRow.mockRejectedValueOnce(new Error('locked'))
275
+ await expect(ctl.deleteRow('1')).rejects.toThrow('locked')
276
+ expect(ctl.getState().rows.some((r) => r.id === 1)).toBe(true) // restored
277
+ expect(ctl.getState().total).toBe(2)
278
+ })
279
+
280
+ it('falls back to a refetch when the row is not on the current page', async () => {
281
+ const src = writable()
282
+ const ctl = createServerDataSource(src, opts())
283
+ ctl.refresh()
284
+ await flush()
285
+ await ctl.updateRow('999', { name: 'ghost' }) // not present locally
286
+ // fell back to mutate() which refreshes: getRows called again
287
+ expect(src.getRows).toHaveBeenCalledTimes(2)
288
+ })
289
+ })