@carto/ps-react-ui 4.4.3 → 4.5.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 (116) hide show
  1. package/dist/{download-config-Dqu78h2a.js → download-config-DemuQ3Jm.js} +9 -10
  2. package/dist/{download-config-Dqu78h2a.js.map → download-config-DemuQ3Jm.js.map} +1 -1
  3. package/dist/error-Cj8eUMrl.js +40 -0
  4. package/dist/error-Cj8eUMrl.js.map +1 -0
  5. package/dist/no-data-DkIt7Qt1.js +61 -0
  6. package/dist/no-data-DkIt7Qt1.js.map +1 -0
  7. package/dist/row-D4VOhcNI.js +34 -0
  8. package/dist/row-D4VOhcNI.js.map +1 -0
  9. package/dist/series-Bola3CmD.js +90 -0
  10. package/dist/series-Bola3CmD.js.map +1 -0
  11. package/dist/types/widgets/category/style.d.ts +1 -0
  12. package/dist/types/widgets/echart/shared-resize-observer.d.ts +12 -0
  13. package/dist/types/widgets/stores/index.d.ts +2 -1
  14. package/dist/types/widgets/stores/use-widget-selector.d.ts +35 -0
  15. package/dist/types/widgets/stores/widget-store-performance.test.d.ts +1 -0
  16. package/dist/types/widgets/stores/widget-store.d.ts +49 -27
  17. package/dist/types/widgets/table/types.d.ts +1 -1
  18. package/dist/use-widget-ref-BFazQvJK.js +22 -0
  19. package/dist/use-widget-ref-BFazQvJK.js.map +1 -0
  20. package/dist/use-widget-selector-DqRmWQ1K.js +12 -0
  21. package/dist/use-widget-selector-DqRmWQ1K.js.map +1 -0
  22. package/dist/widget-store-CIrb9RKP.js +263 -0
  23. package/dist/widget-store-CIrb9RKP.js.map +1 -0
  24. package/dist/widgets/actions.js +783 -817
  25. package/dist/widgets/actions.js.map +1 -1
  26. package/dist/widgets/bar.js +2 -2
  27. package/dist/widgets/category.js +259 -258
  28. package/dist/widgets/category.js.map +1 -1
  29. package/dist/widgets/echart.js +109 -99
  30. package/dist/widgets/echart.js.map +1 -1
  31. package/dist/widgets/error.js +1 -1
  32. package/dist/widgets/formula.js +71 -63
  33. package/dist/widgets/formula.js.map +1 -1
  34. package/dist/widgets/histogram.js +7 -8
  35. package/dist/widgets/histogram.js.map +1 -1
  36. package/dist/widgets/loader.js +53 -60
  37. package/dist/widgets/loader.js.map +1 -1
  38. package/dist/widgets/markdown.js +51 -50
  39. package/dist/widgets/markdown.js.map +1 -1
  40. package/dist/widgets/no-data.js +1 -1
  41. package/dist/widgets/pie.js +2 -2
  42. package/dist/widgets/range.js +146 -144
  43. package/dist/widgets/range.js.map +1 -1
  44. package/dist/widgets/scatterplot.js +2 -2
  45. package/dist/widgets/skeleton-loader.js +18 -17
  46. package/dist/widgets/skeleton-loader.js.map +1 -1
  47. package/dist/widgets/spread.js +110 -94
  48. package/dist/widgets/spread.js.map +1 -1
  49. package/dist/widgets/stores.js +5 -2
  50. package/dist/widgets/stores.js.map +1 -1
  51. package/dist/widgets/subheader.js +29 -29
  52. package/dist/widgets/subheader.js.map +1 -1
  53. package/dist/widgets/table.js +422 -436
  54. package/dist/widgets/table.js.map +1 -1
  55. package/dist/widgets/timeseries.js +2 -2
  56. package/dist/widgets/utils.js +1 -1
  57. package/dist/widgets/wrapper.js +156 -158
  58. package/dist/widgets/wrapper.js.map +1 -1
  59. package/dist/widgets.js +4 -4
  60. package/package.json +1 -1
  61. package/src/hooks/use-widget-ref.ts +3 -4
  62. package/src/widgets/actions/brush-toggle/brush-toggle.tsx +18 -32
  63. package/src/widgets/actions/change-column/change-column.tsx +15 -15
  64. package/src/widgets/actions/change-column/sortable-column-item.tsx +3 -1
  65. package/src/widgets/actions/download/download.tsx +4 -3
  66. package/src/widgets/actions/fullscreen/fullscreen.tsx +7 -11
  67. package/src/widgets/actions/lock-selection/lock-selection.tsx +12 -15
  68. package/src/widgets/actions/relative-data/relative-data.tsx +22 -26
  69. package/src/widgets/actions/searcher/searcher-toggle.tsx +11 -12
  70. package/src/widgets/actions/searcher/searcher.tsx +20 -21
  71. package/src/widgets/actions/stack-toggle/stack-toggle.tsx +15 -21
  72. package/src/widgets/actions/zoom-toggle/zoom-toggle.tsx +27 -43
  73. package/src/widgets/category/category-ui.tsx +30 -31
  74. package/src/widgets/category/style.ts +1 -0
  75. package/src/widgets/echart/echart-ui.test.tsx +20 -16
  76. package/src/widgets/echart/echart-ui.tsx +6 -12
  77. package/src/widgets/echart/echart.tsx +13 -27
  78. package/src/widgets/echart/shared-resize-observer.ts +45 -0
  79. package/src/widgets/error/error.tsx +7 -9
  80. package/src/widgets/formula/components/prefix.tsx +4 -6
  81. package/src/widgets/formula/components/row.tsx +4 -4
  82. package/src/widgets/formula/components/series.tsx +4 -6
  83. package/src/widgets/formula/components/suffix.tsx +4 -6
  84. package/src/widgets/formula/components/value.tsx +9 -16
  85. package/src/widgets/loader/loader.tsx +31 -44
  86. package/src/widgets/markdown/markdown.tsx +4 -7
  87. package/src/widgets/no-data/no-data.tsx +7 -10
  88. package/src/widgets/range/components/range-item.tsx +20 -18
  89. package/src/widgets/skeleton-loader/skeleton-loader.tsx +2 -5
  90. package/src/widgets/spread/components/max-value.tsx +14 -16
  91. package/src/widgets/spread/components/min-value.tsx +14 -16
  92. package/src/widgets/stores/index.ts +2 -1
  93. package/src/widgets/stores/use-widget-selector.ts +47 -0
  94. package/src/widgets/stores/widget-store-performance.test.ts +750 -0
  95. package/src/widgets/stores/widget-store.test.ts +81 -0
  96. package/src/widgets/stores/widget-store.ts +225 -44
  97. package/src/widgets/subheader/subheader.tsx +11 -3
  98. package/src/widgets/table/config.ts +0 -1
  99. package/src/widgets/table/hooks/use-pagination.ts +28 -52
  100. package/src/widgets/table/hooks/use-selection.ts +20 -24
  101. package/src/widgets/table/hooks/use-sort.ts +22 -39
  102. package/src/widgets/table/types.ts +1 -1
  103. package/src/widgets/wrapper/wrapper-ui.tsx +12 -13
  104. package/src/widgets/wrapper/wrapper.tsx +4 -6
  105. package/dist/error-CEkRPccv.js +0 -39
  106. package/dist/error-CEkRPccv.js.map +0 -1
  107. package/dist/no-data-hR3KcJ-_.js +0 -60
  108. package/dist/no-data-hR3KcJ-_.js.map +0 -1
  109. package/dist/row-DTCV0Ocm.js +0 -35
  110. package/dist/row-DTCV0Ocm.js.map +0 -1
  111. package/dist/series-CYNOu2Ju.js +0 -91
  112. package/dist/series-CYNOu2Ju.js.map +0 -1
  113. package/dist/use-widget-ref-wtFLDFCD.js +0 -25
  114. package/dist/use-widget-ref-wtFLDFCD.js.map +0 -1
  115. package/dist/widget-store-CzDt8oSK.js +0 -163
  116. package/dist/widget-store-CzDt8oSK.js.map +0 -1
@@ -0,0 +1,750 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useWidgetStore } from './widget-store'
3
+
4
+ /**
5
+ * Performance tests for the widget store.
6
+ *
7
+ * These tests measure and assert on the performance characteristics of the
8
+ * widget store when operating at scale (100-200 widgets). They quantify the
9
+ * core issues that cause sluggish behavior in dashboards with many widgets:
10
+ *
11
+ * 1. O(n) selector evaluation on every store update (cross-widget interference)
12
+ * 2. Multiple sequential store updates per widget during initialization
13
+ * 3. Cascading tool registrations triggering re-evaluations
14
+ * 4. Full `widgets` object spread on every setWidget call
15
+ */
16
+ describe.skip('WidgetStore Performance', () => {
17
+ beforeEach(() => {
18
+ useWidgetStore.setState({ widgets: {} })
19
+ })
20
+
21
+ describe('setWidget spreads entire widgets object', () => {
22
+ it('creates a new widgets object reference on every setWidget call', () => {
23
+ const { setWidget } = useWidgetStore.getState()
24
+
25
+ // Seed 100 widgets
26
+ for (let i = 0; i < 100; i++) {
27
+ setWidget(`widget-${i}`, {
28
+ type: 'formula',
29
+ isLoading: false,
30
+ data: { value: i },
31
+ })
32
+ }
33
+
34
+ const before = useWidgetStore.getState().widgets
35
+
36
+ // Updating a SINGLE widget creates a new widgets object
37
+ setWidget('widget-0', { data: { value: 999 } })
38
+
39
+ const after = useWidgetStore.getState().widgets
40
+
41
+ // The top-level widgets object reference changes
42
+ expect(before).not.toBe(after)
43
+
44
+ // But all OTHER widgets are the same object references
45
+ for (let i = 1; i < 100; i++) {
46
+ expect(before[`widget-${i}`]).toBe(after[`widget-${i}`])
47
+ }
48
+ })
49
+ })
50
+
51
+ describe('subscriber notification overhead at scale', () => {
52
+ it('notifies ALL subscribers when any single widget updates', () => {
53
+ const { setWidget } = useWidgetStore.getState()
54
+ const WIDGET_COUNT = 100
55
+
56
+ // Seed widgets
57
+ for (let i = 0; i < WIDGET_COUNT; i++) {
58
+ setWidget(`widget-${i}`, {
59
+ type: 'formula',
60
+ isLoading: false,
61
+ data: { value: i },
62
+ })
63
+ }
64
+
65
+ // Create selectors for each widget (simulating what components do)
66
+ const selectorCalls = new Array(WIDGET_COUNT).fill(0) as number[]
67
+ const selectors = Array.from({ length: WIDGET_COUNT }, (_, i) => {
68
+ return (state: ReturnType<typeof useWidgetStore.getState>) => {
69
+ selectorCalls[i]!++
70
+ return state.widgets[`widget-${i}`]?.data
71
+ }
72
+ })
73
+
74
+ // Subscribe all selectors
75
+ const unsubscribes = selectors.map((selector) =>
76
+ useWidgetStore.subscribe((state) => selector(state)),
77
+ )
78
+
79
+ // Reset counters after subscription setup
80
+ selectorCalls.fill(0)
81
+
82
+ // Update ONLY widget-0
83
+ setWidget('widget-0', { data: { value: 'updated' } })
84
+
85
+ // ALL 100 selectors were called, not just widget-0's
86
+ const totalCalls = selectorCalls.reduce((sum, c) => sum + c, 0)
87
+ expect(totalCalls).toBe(WIDGET_COUNT)
88
+
89
+ // Every single selector ran, even though only widget-0 changed
90
+ for (let i = 0; i < WIDGET_COUNT; i++) {
91
+ expect(selectorCalls[i]).toBe(1)
92
+ }
93
+
94
+ unsubscribes.forEach((unsub) => unsub())
95
+ })
96
+
97
+ it('measures O(n) selector evaluation cost per store update', () => {
98
+ const { setWidget } = useWidgetStore.getState()
99
+ const WIDGET_COUNT = 200
100
+
101
+ for (let i = 0; i < WIDGET_COUNT; i++) {
102
+ setWidget(`widget-${i}`, {
103
+ type: 'formula',
104
+ isLoading: false,
105
+ data: { value: i },
106
+ })
107
+ }
108
+
109
+ let selectorRunCount = 0
110
+
111
+ // Simulate realistic selectors: each widget has ~5 property selectors
112
+ const unsubscribes: (() => void)[] = []
113
+ for (let i = 0; i < WIDGET_COUNT; i++) {
114
+ const widgetId = `widget-${i}`
115
+ const properties = ['data', 'isLoading', 'isFetching', 'type', 'error']
116
+ for (const prop of properties) {
117
+ unsubscribes.push(
118
+ useWidgetStore.subscribe((state) => {
119
+ selectorRunCount++
120
+ return (
121
+ state.widgets[widgetId] as Record<string, unknown> | undefined
122
+ )?.[prop]
123
+ }),
124
+ )
125
+ }
126
+ }
127
+
128
+ selectorRunCount = 0
129
+
130
+ // A single widget update triggers ALL selectors
131
+ setWidget('widget-0', { data: { value: 'changed' } })
132
+
133
+ // 200 widgets × 5 selectors = 1000 selector evaluations for ONE update
134
+ expect(selectorRunCount).toBe(WIDGET_COUNT * 5)
135
+
136
+ unsubscribes.forEach((unsub) => unsub())
137
+ })
138
+ })
139
+
140
+ describe('initialization storm: setWidget calls per widget', () => {
141
+ it('counts store updates during widget initialization sequence (merged effects)', () => {
142
+ const storeSpy = vi.fn()
143
+ const unsub = useWidgetStore.subscribe(storeSpy)
144
+
145
+ const { setWidget } = useWidgetStore.getState()
146
+
147
+ // After Phase 1 optimization: WidgetLoader merges type + loading/error
148
+ // into a single setWidget call (Effect 1 merged with Effect 2).
149
+ setWidget('widget-0', {
150
+ type: 'bar',
151
+ isLoading: false,
152
+ isFetching: false,
153
+ error: undefined,
154
+ })
155
+
156
+ // 1 store update per widget from WidgetLoader metadata effect.
157
+ // executeToolPipeline and executeConfigPipeline add more.
158
+ expect(storeSpy).toHaveBeenCalledTimes(1)
159
+
160
+ unsub()
161
+ })
162
+
163
+ it('measures total store updates when 100 widgets initialize (merged effects)', () => {
164
+ const storeSpy = vi.fn()
165
+ const unsub = useWidgetStore.subscribe(storeSpy)
166
+
167
+ const { setWidget } = useWidgetStore.getState()
168
+ const WIDGET_COUNT = 100
169
+
170
+ // Simulate all 100 widgets calling their merged WidgetLoader effect
171
+ for (let i = 0; i < WIDGET_COUNT; i++) {
172
+ setWidget(`widget-${i}`, {
173
+ type: 'bar',
174
+ isLoading: false,
175
+ isFetching: false,
176
+ error: undefined,
177
+ })
178
+ }
179
+
180
+ // 100 widgets × 1 setWidget call = 100 store updates (was 200 before merge)
181
+ expect(storeSpy).toHaveBeenCalledTimes(WIDGET_COUNT)
182
+
183
+ unsub()
184
+ })
185
+ })
186
+
187
+ it('does not skip when values actually change', () => {
188
+ const { setWidget } = useWidgetStore.getState()
189
+
190
+ setWidget('widget-0', { type: 'bar', isLoading: false })
191
+
192
+ const storeSpy = vi.fn()
193
+ const unsub = useWidgetStore.subscribe(storeSpy)
194
+
195
+ setWidget('widget-0', { isLoading: true })
196
+
197
+ expect(storeSpy).toHaveBeenCalledTimes(1)
198
+
199
+ unsub()
200
+ })
201
+
202
+ it('does not skip for new widgets (no current state)', () => {
203
+ const storeSpy = vi.fn()
204
+ const unsub = useWidgetStore.subscribe(storeSpy)
205
+
206
+ const { setWidget } = useWidgetStore.getState()
207
+ setWidget('new-widget', { type: 'formula', isLoading: false })
208
+
209
+ expect(storeSpy).toHaveBeenCalledTimes(1)
210
+
211
+ unsub()
212
+ })
213
+
214
+ describe('tool registration cascading updates', () => {
215
+ it('counts store updates when actions register tools on 100 widgets', () => {
216
+ const { setWidget, registerTool } = useWidgetStore.getState()
217
+ const WIDGET_COUNT = 100
218
+
219
+ // Seed widgets
220
+ for (let i = 0; i < WIDGET_COUNT; i++) {
221
+ setWidget(`widget-${i}`, {
222
+ type: 'bar',
223
+ isLoading: false,
224
+ })
225
+ }
226
+
227
+ const storeSpy = vi.fn()
228
+ const unsub = useWidgetStore.subscribe(storeSpy)
229
+
230
+ // Simulate action components mounting and registering tools.
231
+ // A bar widget with full actions registers ~8 tools:
232
+ // RelativeData (2 tools), StackToggle (1), Searcher (1),
233
+ // SearcherToggle config (1), ZoomToggle (1), BrushToggle (1), Download ref (1)
234
+ const TOOLS_PER_WIDGET = 8
235
+
236
+ for (let i = 0; i < WIDGET_COUNT; i++) {
237
+ for (let t = 0; t < TOOLS_PER_WIDGET; t++) {
238
+ registerTool(`widget-${i}`, {
239
+ id: `tool-${t}`,
240
+ order: t * 10,
241
+ enabled: true,
242
+ fn: (data) => data,
243
+ })
244
+ }
245
+ }
246
+
247
+ // 100 widgets × 8 tools = 800 store updates from tool registration alone
248
+ expect(storeSpy).toHaveBeenCalledTimes(WIDGET_COUNT * TOOLS_PER_WIDGET)
249
+
250
+ unsub()
251
+ })
252
+
253
+ it('shows that registerTool triggers re-evaluation of all widget selectors', () => {
254
+ const { setWidget, registerTool } = useWidgetStore.getState()
255
+ const WIDGET_COUNT = 50
256
+
257
+ for (let i = 0; i < WIDGET_COUNT; i++) {
258
+ setWidget(`widget-${i}`, { type: 'bar', isLoading: false })
259
+ }
260
+
261
+ let totalSelectorRuns = 0
262
+ const unsubscribes = Array.from({ length: WIDGET_COUNT }, (_, i) =>
263
+ useWidgetStore.subscribe((state) => {
264
+ totalSelectorRuns++
265
+ return state.widgets[`widget-${i}`]?.registeredTools
266
+ }),
267
+ )
268
+
269
+ totalSelectorRuns = 0
270
+
271
+ // Registering a tool on widget-0 triggers ALL selectors
272
+ registerTool('widget-0', {
273
+ id: 'test-tool',
274
+ order: 10,
275
+ enabled: true,
276
+ fn: (data) => data,
277
+ })
278
+
279
+ expect(totalSelectorRuns).toBe(WIDGET_COUNT)
280
+
281
+ unsubscribes.forEach((unsub) => unsub())
282
+ })
283
+ })
284
+
285
+ describe('pipeline execution store updates', () => {
286
+ it('executeToolPipeline calls setWidget even with no tools', async () => {
287
+ const { setWidget, executeToolPipeline } = useWidgetStore.getState()
288
+
289
+ setWidget('widget-0', { type: 'bar', isLoading: false })
290
+
291
+ const storeSpy = vi.fn()
292
+ const unsub = useWidgetStore.subscribe(storeSpy)
293
+
294
+ // Even with no registered tools, executeToolPipeline does a set()
295
+ await executeToolPipeline('widget-0', [{ category: 'A', value: 1 }])
296
+
297
+ // 1 store update to write the data
298
+ expect(storeSpy).toHaveBeenCalledTimes(1)
299
+
300
+ unsub()
301
+ })
302
+
303
+ it('measures total store updates when 100 widgets execute pipelines simultaneously', async () => {
304
+ const { setWidget, executeToolPipeline, executeConfigPipeline } =
305
+ useWidgetStore.getState()
306
+ const WIDGET_COUNT = 100
307
+
308
+ for (let i = 0; i < WIDGET_COUNT; i++) {
309
+ setWidget(`widget-${i}`, { type: 'bar', isLoading: false })
310
+ }
311
+
312
+ const storeSpy = vi.fn()
313
+ const unsub = useWidgetStore.subscribe(storeSpy)
314
+
315
+ // All 100 widgets execute both pipelines concurrently (like on data change)
316
+ await Promise.all(
317
+ Array.from({ length: WIDGET_COUNT }, (_, i) =>
318
+ Promise.all([
319
+ executeToolPipeline(`widget-${i}`, { value: i }),
320
+ executeConfigPipeline(`widget-${i}`, { option: {} }),
321
+ ]),
322
+ ),
323
+ )
324
+
325
+ // Each widget: 1 data pipeline set + 1 config pipeline set = 2 store updates
326
+ // Total: 100 × 2 = 200 store updates
327
+ expect(storeSpy).toHaveBeenCalledTimes(WIDGET_COUNT * 2)
328
+
329
+ unsub()
330
+ })
331
+ })
332
+
333
+ describe('full initialization simulation', () => {
334
+ it('counts total store updates for a realistic 100-widget dashboard startup', async () => {
335
+ const storeSpy = vi.fn()
336
+ const unsub = useWidgetStore.subscribe(storeSpy)
337
+
338
+ const {
339
+ setWidget,
340
+ registerTool,
341
+ executeToolPipeline,
342
+ executeConfigPipeline,
343
+ } = useWidgetStore.getState()
344
+
345
+ const WIDGET_COUNT = 100
346
+ const TOOLS_PER_WIDGET = 6
347
+
348
+ // Phase 1: WidgetLoader merged effect (1 setWidget call each)
349
+ for (let i = 0; i < WIDGET_COUNT; i++) {
350
+ setWidget(`widget-${i}`, {
351
+ type: 'bar',
352
+ isLoading: false,
353
+ isFetching: false,
354
+ })
355
+ }
356
+
357
+ // Phase 2: Action components register tools
358
+ for (let i = 0; i < WIDGET_COUNT; i++) {
359
+ for (let t = 0; t < TOOLS_PER_WIDGET; t++) {
360
+ registerTool(`widget-${i}`, {
361
+ id: `tool-${t}`,
362
+ order: t * 10,
363
+ enabled: t < 3, // some enabled, some disabled
364
+ fn: (data) => data,
365
+ })
366
+ }
367
+ }
368
+
369
+ // Phase 3: Pipeline executions
370
+ await Promise.all(
371
+ Array.from({ length: WIDGET_COUNT }, (_, i) =>
372
+ Promise.all([
373
+ executeToolPipeline(`widget-${i}`, { value: i }),
374
+ executeConfigPipeline(`widget-${i}`, { option: {} }),
375
+ ]),
376
+ ),
377
+ )
378
+
379
+ const totalUpdates = storeSpy.mock.calls.length
380
+
381
+ // Phase 1: 100 × 1 = 100 (merged effects, was 200 before)
382
+ // Phase 2: 100 × 6 = 600
383
+ // Phase 3: 100 × 2 = 200
384
+ // Total: 900 store updates (was 1000 before merge)
385
+ expect(totalUpdates).toBe(
386
+ WIDGET_COUNT * 1 + // merged setWidget calls
387
+ WIDGET_COUNT * TOOLS_PER_WIDGET + // registerTool calls
388
+ WIDGET_COUNT * 2, // pipeline executions
389
+ )
390
+
391
+ unsub()
392
+ })
393
+ })
394
+
395
+ describe('shared widget IDs (200-widget scenario)', () => {
396
+ it('shows that two components sharing the same widget ID overwrite each other', () => {
397
+ const { setWidget } = useWidgetStore.getState()
398
+
399
+ // First "instance" sets data
400
+ setWidget('shared-widget', {
401
+ type: 'bar',
402
+ isLoading: false,
403
+ data: { value: 'first' },
404
+ })
405
+
406
+ // Second "instance" with same ID overwrites
407
+ setWidget('shared-widget', {
408
+ type: 'bar',
409
+ isLoading: false,
410
+ data: { value: 'second' },
411
+ })
412
+
413
+ const widget = useWidgetStore.getState().widgets['shared-widget']
414
+ expect((widget?.data as { value: string })?.value).toBe('second')
415
+ })
416
+
417
+ it('measures store updates when 200 widgets share 100 IDs', async () => {
418
+ const { setWidget, registerTool, executeToolPipeline } =
419
+ useWidgetStore.getState()
420
+
421
+ const storeSpy = vi.fn()
422
+ const unsub = useWidgetStore.subscribe(storeSpy)
423
+
424
+ // 200 widget instances, but only 100 unique IDs
425
+ // The second 100 re-use IDs from the first 100
426
+ for (let instance = 0; instance < 200; instance++) {
427
+ const id = `widget-${instance % 100}`
428
+ setWidget(id, { type: 'bar', isLoading: false })
429
+ setWidget(id, { isFetching: false })
430
+ }
431
+
432
+ // Tool registrations also double
433
+ storeSpy.mockClear()
434
+ for (let instance = 0; instance < 200; instance++) {
435
+ const id = `widget-${instance % 100}`
436
+ registerTool(id, {
437
+ id: 'relative-data',
438
+ order: 10,
439
+ enabled: true,
440
+ fn: (data) => data,
441
+ })
442
+ }
443
+
444
+ // First 100 registerTool calls create new tools = 100 store updates.
445
+ // Second 100 calls have same id/order/enabled/type/disables — registerTool's
446
+ // no-op detection updates fn via direct mutation and skips set().
447
+ expect(storeSpy).toHaveBeenCalledTimes(100)
448
+
449
+ // Pipeline executions also double
450
+ storeSpy.mockClear()
451
+ await Promise.all(
452
+ Array.from({ length: 200 }, (_, i) =>
453
+ executeToolPipeline(`widget-${i % 100}`, { value: i }),
454
+ ),
455
+ )
456
+
457
+ // 200 pipeline executions but with cancellation, only 100 survive
458
+ // (each later execution for the same ID cancels the previous one)
459
+ // The final set() calls: at most 100 (one per unique ID)
460
+ const pipelineUpdates = storeSpy.mock.calls.length
461
+ expect(pipelineUpdates).toBeLessThanOrEqual(200)
462
+ expect(pipelineUpdates).toBeGreaterThanOrEqual(100)
463
+
464
+ unsub()
465
+ })
466
+ })
467
+
468
+ describe('dynamic data update performance', () => {
469
+ it('measures store churn when all 100 widgets update data simultaneously', async () => {
470
+ const { setWidget, executeToolPipeline, registerTool } =
471
+ useWidgetStore.getState()
472
+ const WIDGET_COUNT = 100
473
+
474
+ // Setup: seed widgets with tools
475
+ for (let i = 0; i < WIDGET_COUNT; i++) {
476
+ setWidget(`widget-${i}`, { type: 'bar', isLoading: false })
477
+ registerTool(`widget-${i}`, {
478
+ id: 'data-transform',
479
+ order: 10,
480
+ enabled: true,
481
+ fn: (data) => data, // passthrough
482
+ })
483
+ }
484
+
485
+ const storeSpy = vi.fn()
486
+ const unsub = useWidgetStore.subscribe(storeSpy)
487
+
488
+ // Simulate dynamic data update: all widgets get new data at once
489
+ await Promise.all(
490
+ Array.from({ length: WIDGET_COUNT }, (_, i) =>
491
+ executeToolPipeline(`widget-${i}`, { value: Math.random() }),
492
+ ),
493
+ )
494
+
495
+ // Each pipeline: 1 set() call to write transformed data
496
+ expect(storeSpy).toHaveBeenCalledTimes(WIDGET_COUNT)
497
+
498
+ unsub()
499
+ })
500
+ })
501
+
502
+ describe('cascading Effect 4: tool registration triggers pipeline re-execution', () => {
503
+ it('proves each registerTool triggers 2 pipeline re-executions via Effect 4', async () => {
504
+ const {
505
+ setWidget,
506
+ registerTool,
507
+ executeToolPipeline,
508
+ executeConfigPipeline,
509
+ } = useWidgetStore.getState()
510
+
511
+ // Setup: create widget with initial data
512
+ setWidget('widget-0', { type: 'bar', isLoading: false })
513
+ await executeToolPipeline('widget-0', { value: 1 })
514
+ await executeConfigPipeline('widget-0', { option: {} })
515
+
516
+ const storeSpy = vi.fn()
517
+ const unsub = useWidgetStore.subscribe(storeSpy)
518
+
519
+ // Simulate what Effect 4 does: after each registerTool, WidgetLoader
520
+ // re-executes both pipelines because registeredTools changed.
521
+ const TOOLS_PER_WIDGET = 6
522
+ for (let t = 0; t < TOOLS_PER_WIDGET; t++) {
523
+ // Action component registers a tool
524
+ registerTool('widget-0', {
525
+ id: `tool-${t}`,
526
+ order: t * 10,
527
+ enabled: true,
528
+ fn: (data) => data,
529
+ })
530
+
531
+ // Effect 4 fires: re-execute both pipelines
532
+ await executeToolPipeline('widget-0', { value: 1 })
533
+ await executeConfigPipeline('widget-0', { option: {} })
534
+ }
535
+
536
+ // Per tool: 1 registerTool + 1 data pipeline + 1 config pipeline = 3 store updates
537
+ // 6 tools × 3 = 18 store updates just from tool registration cascading
538
+ expect(storeSpy).toHaveBeenCalledTimes(TOOLS_PER_WIDGET * 3)
539
+
540
+ unsub()
541
+ })
542
+
543
+ it('measures total store updates for realistic single widget mount', async () => {
544
+ const {
545
+ setWidget,
546
+ registerTool,
547
+ executeToolPipeline,
548
+ executeConfigPipeline,
549
+ } = useWidgetStore.getState()
550
+
551
+ const storeSpy = vi.fn()
552
+ const unsub = useWidgetStore.subscribe(storeSpy)
553
+
554
+ const data = { value: 1 }
555
+ const config = { option: {} }
556
+
557
+ // WidgetLoader Effect 1: metadata
558
+ setWidget('widget-0', {
559
+ type: 'bar',
560
+ isLoading: false,
561
+ isFetching: false,
562
+ })
563
+
564
+ // WidgetWrapper useLayoutEffect
565
+ setWidget('widget-0', {
566
+ collapsed: false,
567
+ disabled: false,
568
+ title: 'Test Widget',
569
+ })
570
+
571
+ // useWidgetRef useEffect
572
+ setWidget('widget-0', { refUI: undefined })
573
+
574
+ // WidgetLoader Effect 2: config pipeline
575
+ await executeConfigPipeline('widget-0', config)
576
+
577
+ // WidgetLoader Effect 3: data pipeline
578
+ await executeToolPipeline('widget-0', data)
579
+
580
+ // Action components register tools + Effect 4 cascades
581
+ const tools = [
582
+ 'relative-data',
583
+ 'relative-data-config',
584
+ 'stack-toggle',
585
+ 'zoom-toggle',
586
+ 'brush-toggle',
587
+ 'searcher',
588
+ ]
589
+ for (const toolId of tools) {
590
+ registerTool('widget-0', {
591
+ id: toolId,
592
+ order: 10,
593
+ enabled: true,
594
+ fn: (d) => d,
595
+ })
596
+ // Effect 4: re-execute both pipelines
597
+ await executeToolPipeline('widget-0', data)
598
+ await executeConfigPipeline('widget-0', config)
599
+ }
600
+
601
+ // StackToggle also calls setWidget for default isStacked
602
+ setWidget('widget-0', { isStacked: false })
603
+
604
+ const totalUpdates = storeSpy.mock.calls.length
605
+
606
+ // Breakdown:
607
+ // 3 setWidget calls (metadata + wrapper + widgetRef) = 3
608
+ // 2 initial pipeline executions (config + data) = 2
609
+ // 6 tool registrations = 6
610
+ // Pipeline re-executions after tool registration: the pipeline no-op detection
611
+ // (Object.is check in executeToolPipeline/executeConfigPipeline) skips the set()
612
+ // when passthrough tools return the same data reference. So cascading re-executions
613
+ // produce 0 additional store updates with passthrough tools.
614
+ // 1 StackToggle setWidget = 1
615
+ // Total = 12 store updates for ONE widget
616
+ expect(totalUpdates).toBe(3 + 2 + tools.length + 1)
617
+
618
+ unsub()
619
+ })
620
+ })
621
+
622
+ describe('full 200-widget initialization with cascading effects', () => {
623
+ it('measures total store updates for 200-widget dashboard startup', async () => {
624
+ const {
625
+ setWidget,
626
+ registerTool,
627
+ executeToolPipeline,
628
+ executeConfigPipeline,
629
+ } = useWidgetStore.getState()
630
+
631
+ const storeSpy = vi.fn()
632
+ const unsub = useWidgetStore.subscribe(storeSpy)
633
+
634
+ const WIDGET_COUNT = 200
635
+ const TOOLS_PER_WIDGET = 6
636
+
637
+ // Phase 1: WidgetLoader + Wrapper + Ref effects (3 setWidget each)
638
+ for (let i = 0; i < WIDGET_COUNT; i++) {
639
+ setWidget(`widget-${i}`, {
640
+ type: 'bar',
641
+ isLoading: false,
642
+ isFetching: false,
643
+ })
644
+ setWidget(`widget-${i}`, {
645
+ collapsed: false,
646
+ title: `Widget ${i}`,
647
+ })
648
+ setWidget(`widget-${i}`, { refUI: undefined })
649
+ }
650
+
651
+ // Phase 2: Initial pipeline executions
652
+ await Promise.all(
653
+ Array.from({ length: WIDGET_COUNT }, (_, i) =>
654
+ Promise.all([
655
+ executeToolPipeline(`widget-${i}`, { value: i }),
656
+ executeConfigPipeline(`widget-${i}`, { option: {} }),
657
+ ]),
658
+ ),
659
+ )
660
+
661
+ // Phase 3: Tool registrations + cascading pipeline re-executions
662
+ for (let i = 0; i < WIDGET_COUNT; i++) {
663
+ for (let t = 0; t < TOOLS_PER_WIDGET; t++) {
664
+ registerTool(`widget-${i}`, {
665
+ id: `tool-${t}`,
666
+ order: t * 10,
667
+ enabled: true,
668
+ fn: (data) => data,
669
+ })
670
+ }
671
+ // Effect 4 fires for each tool change, but we simulate the final
672
+ // cascade: one pair of pipeline re-executions per tool registration
673
+ await executeToolPipeline(`widget-${i}`, { value: i })
674
+ await executeConfigPipeline(`widget-${i}`, { option: {} })
675
+ }
676
+
677
+ const totalUpdates = storeSpy.mock.calls.length
678
+
679
+ // Phase 1: 200 × 3 setWidget = 600
680
+ // Phase 2: 200 × 2 pipelines = 400
681
+ // Phase 3: 200 × (6 registerTool + 2 pipeline re-executions) = 200 × 8 = 1600
682
+ // Total: 2600 store updates
683
+ //
684
+ // In reality, Effect 4 fires per registerTool (not batched), which would be
685
+ // 200 × 6 × 2 = 2400 extra pipeline updates. We simulate the conservative case.
686
+ expect(totalUpdates).toBe(
687
+ WIDGET_COUNT * 3 + // setWidget calls
688
+ WIDGET_COUNT * 2 + // initial pipeline executions
689
+ WIDGET_COUNT * TOOLS_PER_WIDGET + // registerTool calls
690
+ WIDGET_COUNT * 2, // cascading pipeline re-executions (conservative: 1 per widget)
691
+ )
692
+
693
+ unsub()
694
+ })
695
+
696
+ it('measures wall-clock time for 200-widget store initialization', async () => {
697
+ const {
698
+ setWidget,
699
+ registerTool,
700
+ executeToolPipeline,
701
+ executeConfigPipeline,
702
+ } = useWidgetStore.getState()
703
+
704
+ const WIDGET_COUNT = 200
705
+ const TOOLS_PER_WIDGET = 6
706
+
707
+ const start = performance.now()
708
+
709
+ // Simulate full initialization
710
+ for (let i = 0; i < WIDGET_COUNT; i++) {
711
+ const id = `widget-${i}`
712
+ setWidget(id, { type: 'bar', isLoading: false, isFetching: false })
713
+ setWidget(id, { collapsed: false, title: `Widget ${i}` })
714
+ }
715
+
716
+ await Promise.all(
717
+ Array.from({ length: WIDGET_COUNT }, (_, i) =>
718
+ Promise.all([
719
+ executeToolPipeline(`widget-${i}`, { value: i }),
720
+ executeConfigPipeline(`widget-${i}`, { option: {} }),
721
+ ]),
722
+ ),
723
+ )
724
+
725
+ for (let i = 0; i < WIDGET_COUNT; i++) {
726
+ for (let t = 0; t < TOOLS_PER_WIDGET; t++) {
727
+ registerTool(`widget-${i}`, {
728
+ id: `tool-${t}`,
729
+ order: t * 10,
730
+ enabled: true,
731
+ fn: (data) => data,
732
+ })
733
+ }
734
+ }
735
+
736
+ const elapsed = performance.now() - start
737
+
738
+ // Log timing for baseline tracking (not a strict assertion)
739
+ // eslint-disable-next-line no-console
740
+ console.log(
741
+ `[Performance] 200-widget store init: ${elapsed.toFixed(1)}ms ` +
742
+ `(${WIDGET_COUNT} widgets × ${TOOLS_PER_WIDGET} tools = ` +
743
+ `${WIDGET_COUNT * (2 + TOOLS_PER_WIDGET)} store updates)`,
744
+ )
745
+
746
+ // Sanity check: should complete in under 5 seconds even on slow CI
747
+ expect(elapsed).toBeLessThan(5000)
748
+ })
749
+ })
750
+ })