@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.
- package/dist/{download-config-Dqu78h2a.js → download-config-DemuQ3Jm.js} +9 -10
- package/dist/{download-config-Dqu78h2a.js.map → download-config-DemuQ3Jm.js.map} +1 -1
- package/dist/error-Cj8eUMrl.js +40 -0
- package/dist/error-Cj8eUMrl.js.map +1 -0
- package/dist/no-data-DkIt7Qt1.js +61 -0
- package/dist/no-data-DkIt7Qt1.js.map +1 -0
- package/dist/row-D4VOhcNI.js +34 -0
- package/dist/row-D4VOhcNI.js.map +1 -0
- package/dist/series-Bola3CmD.js +90 -0
- package/dist/series-Bola3CmD.js.map +1 -0
- package/dist/types/widgets/category/style.d.ts +1 -0
- package/dist/types/widgets/echart/shared-resize-observer.d.ts +12 -0
- package/dist/types/widgets/stores/index.d.ts +2 -1
- package/dist/types/widgets/stores/use-widget-selector.d.ts +35 -0
- package/dist/types/widgets/stores/widget-store-performance.test.d.ts +1 -0
- package/dist/types/widgets/stores/widget-store.d.ts +49 -27
- package/dist/types/widgets/table/types.d.ts +1 -1
- package/dist/use-widget-ref-BFazQvJK.js +22 -0
- package/dist/use-widget-ref-BFazQvJK.js.map +1 -0
- package/dist/use-widget-selector-DqRmWQ1K.js +12 -0
- package/dist/use-widget-selector-DqRmWQ1K.js.map +1 -0
- package/dist/widget-store-CIrb9RKP.js +263 -0
- package/dist/widget-store-CIrb9RKP.js.map +1 -0
- package/dist/widgets/actions.js +783 -817
- package/dist/widgets/actions.js.map +1 -1
- package/dist/widgets/bar.js +2 -2
- package/dist/widgets/category.js +259 -258
- package/dist/widgets/category.js.map +1 -1
- package/dist/widgets/echart.js +109 -99
- package/dist/widgets/echart.js.map +1 -1
- package/dist/widgets/error.js +1 -1
- package/dist/widgets/formula.js +71 -63
- package/dist/widgets/formula.js.map +1 -1
- package/dist/widgets/histogram.js +7 -8
- package/dist/widgets/histogram.js.map +1 -1
- package/dist/widgets/loader.js +53 -60
- package/dist/widgets/loader.js.map +1 -1
- package/dist/widgets/markdown.js +51 -50
- package/dist/widgets/markdown.js.map +1 -1
- package/dist/widgets/no-data.js +1 -1
- package/dist/widgets/pie.js +2 -2
- package/dist/widgets/range.js +146 -144
- package/dist/widgets/range.js.map +1 -1
- package/dist/widgets/scatterplot.js +2 -2
- package/dist/widgets/skeleton-loader.js +18 -17
- package/dist/widgets/skeleton-loader.js.map +1 -1
- package/dist/widgets/spread.js +110 -94
- package/dist/widgets/spread.js.map +1 -1
- package/dist/widgets/stores.js +5 -2
- package/dist/widgets/stores.js.map +1 -1
- package/dist/widgets/subheader.js +29 -29
- package/dist/widgets/subheader.js.map +1 -1
- package/dist/widgets/table.js +422 -436
- package/dist/widgets/table.js.map +1 -1
- package/dist/widgets/timeseries.js +2 -2
- package/dist/widgets/utils.js +1 -1
- package/dist/widgets/wrapper.js +156 -158
- package/dist/widgets/wrapper.js.map +1 -1
- package/dist/widgets.js +4 -4
- package/package.json +1 -1
- package/src/hooks/use-widget-ref.ts +3 -4
- package/src/widgets/actions/brush-toggle/brush-toggle.tsx +18 -32
- package/src/widgets/actions/change-column/change-column.tsx +15 -15
- package/src/widgets/actions/change-column/sortable-column-item.tsx +3 -1
- package/src/widgets/actions/download/download.tsx +4 -3
- package/src/widgets/actions/fullscreen/fullscreen.tsx +7 -11
- package/src/widgets/actions/lock-selection/lock-selection.tsx +12 -15
- package/src/widgets/actions/relative-data/relative-data.tsx +22 -26
- package/src/widgets/actions/searcher/searcher-toggle.tsx +11 -12
- package/src/widgets/actions/searcher/searcher.tsx +20 -21
- package/src/widgets/actions/stack-toggle/stack-toggle.tsx +15 -21
- package/src/widgets/actions/zoom-toggle/zoom-toggle.tsx +27 -43
- package/src/widgets/category/category-ui.tsx +30 -31
- package/src/widgets/category/style.ts +1 -0
- package/src/widgets/echart/echart-ui.test.tsx +20 -16
- package/src/widgets/echart/echart-ui.tsx +6 -12
- package/src/widgets/echart/echart.tsx +13 -27
- package/src/widgets/echart/shared-resize-observer.ts +45 -0
- package/src/widgets/error/error.tsx +7 -9
- package/src/widgets/formula/components/prefix.tsx +4 -6
- package/src/widgets/formula/components/row.tsx +4 -4
- package/src/widgets/formula/components/series.tsx +4 -6
- package/src/widgets/formula/components/suffix.tsx +4 -6
- package/src/widgets/formula/components/value.tsx +9 -16
- package/src/widgets/loader/loader.tsx +31 -44
- package/src/widgets/markdown/markdown.tsx +4 -7
- package/src/widgets/no-data/no-data.tsx +7 -10
- package/src/widgets/range/components/range-item.tsx +20 -18
- package/src/widgets/skeleton-loader/skeleton-loader.tsx +2 -5
- package/src/widgets/spread/components/max-value.tsx +14 -16
- package/src/widgets/spread/components/min-value.tsx +14 -16
- package/src/widgets/stores/index.ts +2 -1
- package/src/widgets/stores/use-widget-selector.ts +47 -0
- package/src/widgets/stores/widget-store-performance.test.ts +750 -0
- package/src/widgets/stores/widget-store.test.ts +81 -0
- package/src/widgets/stores/widget-store.ts +225 -44
- package/src/widgets/subheader/subheader.tsx +11 -3
- package/src/widgets/table/config.ts +0 -1
- package/src/widgets/table/hooks/use-pagination.ts +28 -52
- package/src/widgets/table/hooks/use-selection.ts +20 -24
- package/src/widgets/table/hooks/use-sort.ts +22 -39
- package/src/widgets/table/types.ts +1 -1
- package/src/widgets/wrapper/wrapper-ui.tsx +12 -13
- package/src/widgets/wrapper/wrapper.tsx +4 -6
- package/dist/error-CEkRPccv.js +0 -39
- package/dist/error-CEkRPccv.js.map +0 -1
- package/dist/no-data-hR3KcJ-_.js +0 -60
- package/dist/no-data-hR3KcJ-_.js.map +0 -1
- package/dist/row-DTCV0Ocm.js +0 -35
- package/dist/row-DTCV0Ocm.js.map +0 -1
- package/dist/series-CYNOu2Ju.js +0 -91
- package/dist/series-CYNOu2Ju.js.map +0 -1
- package/dist/use-widget-ref-wtFLDFCD.js +0 -25
- package/dist/use-widget-ref-wtFLDFCD.js.map +0 -1
- package/dist/widget-store-CzDt8oSK.js +0 -163
- 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
|
+
})
|