import { describe, it, expect, beforeEach, vi } from 'vitest' import { useWidgetStore } from './widget-store' /** * Performance tests for the widget store. * * These tests measure and assert on the performance characteristics of the * widget store when operating at scale (100-200 widgets). They quantify the * core issues that cause sluggish behavior in dashboards with many widgets: * * 1. O(n) selector evaluation on every store update (cross-widget interference) * 2. Multiple sequential store updates per widget during initialization * 3. Cascading tool registrations triggering re-evaluations * 4. Full `widgets` object spread on every setWidget call */ describe.skip('WidgetStore Performance', () => { beforeEach(() => { useWidgetStore.setState({ widgets: {} }) }) describe('setWidget spreads entire widgets object', () => { it('creates a new widgets object reference on every setWidget call', () => { const { setWidget } = useWidgetStore.getState() // Seed 100 widgets for (let i = 0; i < 100; i++) { setWidget(`widget-${i}`, { type: 'formula', isLoading: false, data: { value: i }, }) } const before = useWidgetStore.getState().widgets // Updating a SINGLE widget creates a new widgets object setWidget('widget-0', { data: { value: 999 } }) const after = useWidgetStore.getState().widgets // The top-level widgets object reference changes expect(before).not.toBe(after) // But all OTHER widgets are the same object references for (let i = 1; i < 100; i++) { expect(before[`widget-${i}`]).toBe(after[`widget-${i}`]) } }) }) describe('subscriber notification overhead at scale', () => { it('notifies ALL subscribers when any single widget updates', () => { const { setWidget } = useWidgetStore.getState() const WIDGET_COUNT = 100 // Seed widgets for (let i = 0; i < WIDGET_COUNT; i++) { setWidget(`widget-${i}`, { type: 'formula', isLoading: false, data: { value: i }, }) } // Create selectors for each widget (simulating what components do) const selectorCalls = new Array(WIDGET_COUNT).fill(0) as number[] const selectors = Array.from({ length: WIDGET_COUNT }, (_, i) => { return (state: ReturnType) => { selectorCalls[i]!++ return state.widgets[`widget-${i}`]?.data } }) // Subscribe all selectors const unsubscribes = selectors.map((selector) => useWidgetStore.subscribe((state) => selector(state)), ) // Reset counters after subscription setup selectorCalls.fill(0) // Update ONLY widget-0 setWidget('widget-0', { data: { value: 'updated' } }) // ALL 100 selectors were called, not just widget-0's const totalCalls = selectorCalls.reduce((sum, c) => sum + c, 0) expect(totalCalls).toBe(WIDGET_COUNT) // Every single selector ran, even though only widget-0 changed for (let i = 0; i < WIDGET_COUNT; i++) { expect(selectorCalls[i]).toBe(1) } unsubscribes.forEach((unsub) => unsub()) }) it('measures O(n) selector evaluation cost per store update', () => { const { setWidget } = useWidgetStore.getState() const WIDGET_COUNT = 200 for (let i = 0; i < WIDGET_COUNT; i++) { setWidget(`widget-${i}`, { type: 'formula', isLoading: false, data: { value: i }, }) } let selectorRunCount = 0 // Simulate realistic selectors: each widget has ~5 property selectors const unsubscribes: (() => void)[] = [] for (let i = 0; i < WIDGET_COUNT; i++) { const widgetId = `widget-${i}` const properties = ['data', 'isLoading', 'isFetching', 'type', 'error'] for (const prop of properties) { unsubscribes.push( useWidgetStore.subscribe((state) => { selectorRunCount++ return ( state.widgets[widgetId] as Record | undefined )?.[prop] }), ) } } selectorRunCount = 0 // A single widget update triggers ALL selectors setWidget('widget-0', { data: { value: 'changed' } }) // 200 widgets × 5 selectors = 1000 selector evaluations for ONE update expect(selectorRunCount).toBe(WIDGET_COUNT * 5) unsubscribes.forEach((unsub) => unsub()) }) }) describe('initialization storm: setWidget calls per widget', () => { it('counts store updates during widget initialization sequence (merged effects)', () => { const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) const { setWidget } = useWidgetStore.getState() // After Phase 1 optimization: WidgetLoader merges type + loading/error // into a single setWidget call (Effect 1 merged with Effect 2). setWidget('widget-0', { type: 'bar', isLoading: false, isFetching: false, error: undefined, }) // 1 store update per widget from WidgetLoader metadata effect. // executeToolPipeline and executeConfigPipeline add more. expect(storeSpy).toHaveBeenCalledTimes(1) unsub() }) it('measures total store updates when 100 widgets initialize (merged effects)', () => { const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) const { setWidget } = useWidgetStore.getState() const WIDGET_COUNT = 100 // Simulate all 100 widgets calling their merged WidgetLoader effect for (let i = 0; i < WIDGET_COUNT; i++) { setWidget(`widget-${i}`, { type: 'bar', isLoading: false, isFetching: false, error: undefined, }) } // 100 widgets × 1 setWidget call = 100 store updates (was 200 before merge) expect(storeSpy).toHaveBeenCalledTimes(WIDGET_COUNT) unsub() }) }) it('does not skip when values actually change', () => { const { setWidget } = useWidgetStore.getState() setWidget('widget-0', { type: 'bar', isLoading: false }) const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) setWidget('widget-0', { isLoading: true }) expect(storeSpy).toHaveBeenCalledTimes(1) unsub() }) it('does not skip for new widgets (no current state)', () => { const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) const { setWidget } = useWidgetStore.getState() setWidget('new-widget', { type: 'formula', isLoading: false }) expect(storeSpy).toHaveBeenCalledTimes(1) unsub() }) describe('tool registration cascading updates', () => { it('counts store updates when actions register tools on 100 widgets', () => { const { setWidget, registerTool } = useWidgetStore.getState() const WIDGET_COUNT = 100 // Seed widgets for (let i = 0; i < WIDGET_COUNT; i++) { setWidget(`widget-${i}`, { type: 'bar', isLoading: false, }) } const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) // Simulate action components mounting and registering tools. // A bar widget with full actions registers ~8 tools: // RelativeData (2 tools), StackToggle (1), Searcher (1), // SearcherToggle config (1), ZoomToggle (1), BrushToggle (1), Download ref (1) const TOOLS_PER_WIDGET = 8 for (let i = 0; i < WIDGET_COUNT; i++) { for (let t = 0; t < TOOLS_PER_WIDGET; t++) { registerTool(`widget-${i}`, { id: `tool-${t}`, order: t * 10, enabled: true, fn: (data) => data, }) } } // 100 widgets × 8 tools = 800 store updates from tool registration alone expect(storeSpy).toHaveBeenCalledTimes(WIDGET_COUNT * TOOLS_PER_WIDGET) unsub() }) it('shows that registerTool triggers re-evaluation of all widget selectors', () => { const { setWidget, registerTool } = useWidgetStore.getState() const WIDGET_COUNT = 50 for (let i = 0; i < WIDGET_COUNT; i++) { setWidget(`widget-${i}`, { type: 'bar', isLoading: false }) } let totalSelectorRuns = 0 const unsubscribes = Array.from({ length: WIDGET_COUNT }, (_, i) => useWidgetStore.subscribe((state) => { totalSelectorRuns++ return state.widgets[`widget-${i}`]?.registeredTools }), ) totalSelectorRuns = 0 // Registering a tool on widget-0 triggers ALL selectors registerTool('widget-0', { id: 'test-tool', order: 10, enabled: true, fn: (data) => data, }) expect(totalSelectorRuns).toBe(WIDGET_COUNT) unsubscribes.forEach((unsub) => unsub()) }) }) describe('pipeline execution store updates', () => { it('executeToolPipeline calls setWidget even with no tools', async () => { const { setWidget, executeToolPipeline } = useWidgetStore.getState() setWidget('widget-0', { type: 'bar', isLoading: false }) const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) // Even with no registered tools, executeToolPipeline does a set() await executeToolPipeline('widget-0', [{ category: 'A', value: 1 }]) // 1 store update to write the data expect(storeSpy).toHaveBeenCalledTimes(1) unsub() }) it('measures total store updates when 100 widgets execute pipelines simultaneously', async () => { const { setWidget, executeToolPipeline, executeConfigPipeline } = useWidgetStore.getState() const WIDGET_COUNT = 100 for (let i = 0; i < WIDGET_COUNT; i++) { setWidget(`widget-${i}`, { type: 'bar', isLoading: false }) } const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) // All 100 widgets execute both pipelines concurrently (like on data change) await Promise.all( Array.from({ length: WIDGET_COUNT }, (_, i) => Promise.all([ executeToolPipeline(`widget-${i}`, { value: i }), executeConfigPipeline(`widget-${i}`, { option: {} }), ]), ), ) // Each widget: 1 data pipeline set + 1 config pipeline set = 2 store updates // Total: 100 × 2 = 200 store updates expect(storeSpy).toHaveBeenCalledTimes(WIDGET_COUNT * 2) unsub() }) }) describe('full initialization simulation', () => { it('counts total store updates for a realistic 100-widget dashboard startup', async () => { const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) const { setWidget, registerTool, executeToolPipeline, executeConfigPipeline, } = useWidgetStore.getState() const WIDGET_COUNT = 100 const TOOLS_PER_WIDGET = 6 // Phase 1: WidgetLoader merged effect (1 setWidget call each) for (let i = 0; i < WIDGET_COUNT; i++) { setWidget(`widget-${i}`, { type: 'bar', isLoading: false, isFetching: false, }) } // Phase 2: Action components register tools for (let i = 0; i < WIDGET_COUNT; i++) { for (let t = 0; t < TOOLS_PER_WIDGET; t++) { registerTool(`widget-${i}`, { id: `tool-${t}`, order: t * 10, enabled: t < 3, // some enabled, some disabled fn: (data) => data, }) } } // Phase 3: Pipeline executions await Promise.all( Array.from({ length: WIDGET_COUNT }, (_, i) => Promise.all([ executeToolPipeline(`widget-${i}`, { value: i }), executeConfigPipeline(`widget-${i}`, { option: {} }), ]), ), ) const totalUpdates = storeSpy.mock.calls.length // Phase 1: 100 × 1 = 100 (merged effects, was 200 before) // Phase 2: 100 × 6 = 600 // Phase 3: 100 × 2 = 200 // Total: 900 store updates (was 1000 before merge) expect(totalUpdates).toBe( WIDGET_COUNT * 1 + // merged setWidget calls WIDGET_COUNT * TOOLS_PER_WIDGET + // registerTool calls WIDGET_COUNT * 2, // pipeline executions ) unsub() }) }) describe('shared widget IDs (200-widget scenario)', () => { it('shows that two components sharing the same widget ID overwrite each other', () => { const { setWidget } = useWidgetStore.getState() // First "instance" sets data setWidget('shared-widget', { type: 'bar', isLoading: false, data: { value: 'first' }, }) // Second "instance" with same ID overwrites setWidget('shared-widget', { type: 'bar', isLoading: false, data: { value: 'second' }, }) const widget = useWidgetStore.getState().widgets['shared-widget'] expect((widget?.data as { value: string })?.value).toBe('second') }) it('measures store updates when 200 widgets share 100 IDs', async () => { const { setWidget, registerTool, executeToolPipeline } = useWidgetStore.getState() const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) // 200 widget instances, but only 100 unique IDs // The second 100 re-use IDs from the first 100 for (let instance = 0; instance < 200; instance++) { const id = `widget-${instance % 100}` setWidget(id, { type: 'bar', isLoading: false }) setWidget(id, { isFetching: false }) } // Tool registrations also double storeSpy.mockClear() for (let instance = 0; instance < 200; instance++) { const id = `widget-${instance % 100}` registerTool(id, { id: 'relative-data', order: 10, enabled: true, fn: (data) => data, }) } // First 100 registerTool calls create new tools = 100 store updates. // Second 100 calls have same id/order/enabled/type/disables — registerTool's // no-op detection updates fn via direct mutation and skips set(). expect(storeSpy).toHaveBeenCalledTimes(100) // Pipeline executions also double storeSpy.mockClear() await Promise.all( Array.from({ length: 200 }, (_, i) => executeToolPipeline(`widget-${i % 100}`, { value: i }), ), ) // 200 pipeline executions but with cancellation, only 100 survive // (each later execution for the same ID cancels the previous one) // The final set() calls: at most 100 (one per unique ID) const pipelineUpdates = storeSpy.mock.calls.length expect(pipelineUpdates).toBeLessThanOrEqual(200) expect(pipelineUpdates).toBeGreaterThanOrEqual(100) unsub() }) }) describe('dynamic data update performance', () => { it('measures store churn when all 100 widgets update data simultaneously', async () => { const { setWidget, executeToolPipeline, registerTool } = useWidgetStore.getState() const WIDGET_COUNT = 100 // Setup: seed widgets with tools for (let i = 0; i < WIDGET_COUNT; i++) { setWidget(`widget-${i}`, { type: 'bar', isLoading: false }) registerTool(`widget-${i}`, { id: 'data-transform', order: 10, enabled: true, fn: (data) => data, // passthrough }) } const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) // Simulate dynamic data update: all widgets get new data at once await Promise.all( Array.from({ length: WIDGET_COUNT }, (_, i) => executeToolPipeline(`widget-${i}`, { value: Math.random() }), ), ) // Each pipeline: 1 set() call to write transformed data expect(storeSpy).toHaveBeenCalledTimes(WIDGET_COUNT) unsub() }) }) describe('cascading Effect 4: tool registration triggers pipeline re-execution', () => { it('proves each registerTool triggers 2 pipeline re-executions via Effect 4', async () => { const { setWidget, registerTool, executeToolPipeline, executeConfigPipeline, } = useWidgetStore.getState() // Setup: create widget with initial data setWidget('widget-0', { type: 'bar', isLoading: false }) await executeToolPipeline('widget-0', { value: 1 }) await executeConfigPipeline('widget-0', { option: {} }) const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) // Simulate what Effect 4 does: after each registerTool, WidgetLoader // re-executes both pipelines because registeredTools changed. const TOOLS_PER_WIDGET = 6 for (let t = 0; t < TOOLS_PER_WIDGET; t++) { // Action component registers a tool registerTool('widget-0', { id: `tool-${t}`, order: t * 10, enabled: true, fn: (data) => data, }) // Effect 4 fires: re-execute both pipelines await executeToolPipeline('widget-0', { value: 1 }) await executeConfigPipeline('widget-0', { option: {} }) } // Per tool: 1 registerTool + 1 data pipeline + 1 config pipeline = 3 store updates // 6 tools × 3 = 18 store updates just from tool registration cascading expect(storeSpy).toHaveBeenCalledTimes(TOOLS_PER_WIDGET * 3) unsub() }) it('measures total store updates for realistic single widget mount', async () => { const { setWidget, registerTool, executeToolPipeline, executeConfigPipeline, } = useWidgetStore.getState() const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) const data = { value: 1 } const config = { option: {} } // WidgetLoader Effect 1: metadata setWidget('widget-0', { type: 'bar', isLoading: false, isFetching: false, }) // WidgetWrapper useLayoutEffect setWidget('widget-0', { collapsed: false, disabled: false, title: 'Test Widget', }) // useWidgetRef useEffect setWidget('widget-0', { refUI: undefined }) // WidgetLoader Effect 2: config pipeline await executeConfigPipeline('widget-0', config) // WidgetLoader Effect 3: data pipeline await executeToolPipeline('widget-0', data) // Action components register tools + Effect 4 cascades const tools = [ 'relative-data', 'relative-data-config', 'stack-toggle', 'zoom-toggle', 'brush-toggle', 'searcher', ] for (const toolId of tools) { registerTool('widget-0', { id: toolId, order: 10, enabled: true, fn: (d) => d, }) // Effect 4: re-execute both pipelines await executeToolPipeline('widget-0', data) await executeConfigPipeline('widget-0', config) } // StackToggle also calls setWidget for default isStacked setWidget('widget-0', { isStacked: false }) const totalUpdates = storeSpy.mock.calls.length // Breakdown: // 3 setWidget calls (metadata + wrapper + widgetRef) = 3 // 2 initial pipeline executions (config + data) = 2 // 6 tool registrations = 6 // Pipeline re-executions after tool registration: the pipeline no-op detection // (Object.is check in executeToolPipeline/executeConfigPipeline) skips the set() // when passthrough tools return the same data reference. So cascading re-executions // produce 0 additional store updates with passthrough tools. // 1 StackToggle setWidget = 1 // Total = 12 store updates for ONE widget expect(totalUpdates).toBe(3 + 2 + tools.length + 1) unsub() }) }) describe('full 200-widget initialization with cascading effects', () => { it('measures total store updates for 200-widget dashboard startup', async () => { const { setWidget, registerTool, executeToolPipeline, executeConfigPipeline, } = useWidgetStore.getState() const storeSpy = vi.fn() const unsub = useWidgetStore.subscribe(storeSpy) const WIDGET_COUNT = 200 const TOOLS_PER_WIDGET = 6 // Phase 1: WidgetLoader + Wrapper + Ref effects (3 setWidget each) for (let i = 0; i < WIDGET_COUNT; i++) { setWidget(`widget-${i}`, { type: 'bar', isLoading: false, isFetching: false, }) setWidget(`widget-${i}`, { collapsed: false, title: `Widget ${i}`, }) setWidget(`widget-${i}`, { refUI: undefined }) } // Phase 2: Initial pipeline executions await Promise.all( Array.from({ length: WIDGET_COUNT }, (_, i) => Promise.all([ executeToolPipeline(`widget-${i}`, { value: i }), executeConfigPipeline(`widget-${i}`, { option: {} }), ]), ), ) // Phase 3: Tool registrations + cascading pipeline re-executions for (let i = 0; i < WIDGET_COUNT; i++) { for (let t = 0; t < TOOLS_PER_WIDGET; t++) { registerTool(`widget-${i}`, { id: `tool-${t}`, order: t * 10, enabled: true, fn: (data) => data, }) } // Effect 4 fires for each tool change, but we simulate the final // cascade: one pair of pipeline re-executions per tool registration await executeToolPipeline(`widget-${i}`, { value: i }) await executeConfigPipeline(`widget-${i}`, { option: {} }) } const totalUpdates = storeSpy.mock.calls.length // Phase 1: 200 × 3 setWidget = 600 // Phase 2: 200 × 2 pipelines = 400 // Phase 3: 200 × (6 registerTool + 2 pipeline re-executions) = 200 × 8 = 1600 // Total: 2600 store updates // // In reality, Effect 4 fires per registerTool (not batched), which would be // 200 × 6 × 2 = 2400 extra pipeline updates. We simulate the conservative case. expect(totalUpdates).toBe( WIDGET_COUNT * 3 + // setWidget calls WIDGET_COUNT * 2 + // initial pipeline executions WIDGET_COUNT * TOOLS_PER_WIDGET + // registerTool calls WIDGET_COUNT * 2, // cascading pipeline re-executions (conservative: 1 per widget) ) unsub() }) it('measures wall-clock time for 200-widget store initialization', async () => { const { setWidget, registerTool, executeToolPipeline, executeConfigPipeline, } = useWidgetStore.getState() const WIDGET_COUNT = 200 const TOOLS_PER_WIDGET = 6 const start = performance.now() // Simulate full initialization for (let i = 0; i < WIDGET_COUNT; i++) { const id = `widget-${i}` setWidget(id, { type: 'bar', isLoading: false, isFetching: false }) setWidget(id, { collapsed: false, title: `Widget ${i}` }) } await Promise.all( Array.from({ length: WIDGET_COUNT }, (_, i) => Promise.all([ executeToolPipeline(`widget-${i}`, { value: i }), executeConfigPipeline(`widget-${i}`, { option: {} }), ]), ), ) for (let i = 0; i < WIDGET_COUNT; i++) { for (let t = 0; t < TOOLS_PER_WIDGET; t++) { registerTool(`widget-${i}`, { id: `tool-${t}`, order: t * 10, enabled: true, fn: (data) => data, }) } } const elapsed = performance.now() - start // Log timing for baseline tracking (not a strict assertion) // eslint-disable-next-line no-console console.log( `[Performance] 200-widget store init: ${elapsed.toFixed(1)}ms ` + `(${WIDGET_COUNT} widgets × ${TOOLS_PER_WIDGET} tools = ` + `${WIDGET_COUNT * (2 + TOOLS_PER_WIDGET)} store updates)`, ) // Sanity check: should complete in under 5 seconds even on slow CI expect(elapsed).toBeLessThan(5000) }) }) })