import { create } from 'zustand' import type { WidgetState, WidgetStore, ToolRegistration, WidgetStoreActions, } from './types' // Track active pipeline executions for cancellation const activePipelines = new Map() const activeConfigPipelines = new Map() /** * Zustand store for managing widget state across the application. * * Provides centralized state management for all widget UI components, including * data/config transformation pipelines via registered tools. * * **Performance optimizations:** * - `registerTool` skips the store update when structural properties (order, enabled, * type, disables) haven't changed — only `fn` is updated via direct mutation, * avoiding a new `registeredTools` array reference and WidgetLoader pipeline cascades. * - `setToolEnabled` skips the store update when the enabled state is already the * requested value. * - `executeToolPipeline` / `executeConfigPipeline` skip the final `set()` when * the transformed data/config is referentially identical to what's already in the store. * - Both pipelines support cancellation — newer executions for the same widget * automatically cancel in-progress ones. * * @example Reading widget state (prefer useWidgetSelector for performance) * ```tsx * import { useWidgetSelector } from '@carto/ps-react-ui/widgets' * * function MyWidget({ id }: { id: string }) { * const { title, data } = useWidgetSelector(id, (w) => ({ * title: w?.title, * data: w?.data, * })) * return
{title}: {JSON.stringify(data)}
* } * ``` * * @example Writing widget state * ```tsx * import { widgetStoreActions } from '@carto/ps-react-ui/widgets' * * const { setWidget } = widgetStoreActions * setWidget('my-widget', { type: 'formula', isLoading: false, data: { value: 1000 } }) * ``` */ export const useWidgetStore = create()((set, get) => ({ // State widgets: {}, // Actions /** Merges partial state into the widget entry. Creates the widget if it doesn't exist. */ setWidget: (id, widget) => { set((state) => { const prev = state.widgets[id] ?? ({} as WidgetStore['widgets'][string]) return { widgets: { ...state.widgets, [id]: { ...prev, ...widget, id, }, }, } }) }, removeWidget: (id) => set((state) => { const widgets = { ...state.widgets } delete widgets[id] return { widgets } }), clearWidgets: () => set({ widgets: {}, }), getWidget: (id: string) => { return get().widgets[id] as T | undefined }, /** * Registers a transformation tool for a widget's data or config pipeline. * * **No-op optimization:** When a tool with the same `id` already exists and its * structural properties (`order`, `enabled`, `type`, `disables`) are unchanged, * only `fn` is updated via direct mutation — no store update is triggered. This * allows action components to include all reactive dependencies in their * `useEffect` arrays without causing WidgetLoader pipeline cascades. */ registerTool: (widgetId: string, tool: ToolRegistration) => { const current = get().widgets[widgetId] const existingTool = current?.registeredTools?.find( (t: ToolRegistration) => t.id === tool.id, ) // No-op: structural properties unchanged — update fn via direct mutation. // Safe because fn is only consumed imperatively during pipeline execution. if ( existingTool?.order === tool.order && existingTool.enabled === tool.enabled && existingTool.type === tool.type && existingTool.disables === tool.disables ) { existingTool.fn = tool.fn return } set((state) => { const widget = state.widgets[widgetId] ?? ({} as WidgetState) const registeredTools = widget.registeredTools ?? [] // Remove existing tool with same id if present const filteredTools = registeredTools.filter( (t: ToolRegistration) => t.id !== tool.id, ) return { widgets: { ...state.widgets, [widgetId]: { ...widget, id: widgetId, registeredTools: [...filteredTools, tool], }, }, } }) }, unregisterTool: (widgetId: string, toolId: string) => set((state) => { const current = state.widgets[widgetId] if (!current) return state const registeredTools = current.registeredTools ?? [] const filteredTools = registeredTools.filter( (t: ToolRegistration) => t.id !== toolId, ) return { widgets: { ...state.widgets, [widgetId]: { ...current, registeredTools: filteredTools, }, }, } }), /** * Triggers pipeline re-execution by creating a new `registeredTools` reference. * This is a lightweight operation that only bumps the array reference without * modifying any tool. */ triggerToolPipeline: (widgetId: string) => set((state) => { const widget = state.widgets[widgetId] if (!widget) return state return { widgets: { ...state.widgets, [widgetId]: { ...widget, registeredTools: [...(widget.registeredTools ?? [])], }, }, } }), /** * Updates a tool's enabled state. * * **No-op optimization:** Skips the store update if the tool already has the * requested enabled value. */ setToolEnabled: (widgetId: string, toolId: string, enabled: boolean) => { const current = get().widgets[widgetId] if (current) { const tool = current.registeredTools?.find( (t: ToolRegistration) => t.id === toolId, ) if (tool?.enabled === enabled) return } set((state) => { const widget = state.widgets[widgetId] if (!widget) return state const registeredTools = widget.registeredTools ?? [] const updatedTools = registeredTools.map((tool: ToolRegistration) => tool.id === toolId ? { ...tool, enabled } : tool, ) return { widgets: { ...state.widgets, [widgetId]: { ...widget, registeredTools: updatedTools, }, }, } }) }, /** * Executes the data transformation pipeline for a widget. * * Filters to enabled data-type tools (respecting `disables`), sorts by `order`, * and chains their `fn` calls. Supports async tools. * * **Cancellation:** Newer executions for the same widget automatically cancel * in-progress ones via a version counter. * * **No-op optimization:** Skips the final `set()` if the transformed data is * referentially identical (`Object.is`) to what's already in the store. */ executeToolPipeline: async (widgetId: string, sourceData: unknown) => { const widget = get().widgets[widgetId] if (!widget) return // Cancel any in-progress pipeline for this widget const currentExecution = (activePipelines.get(widgetId) ?? 0) + 1 activePipelines.set(widgetId, currentExecution) const widgetWithTools = widget // Build set of tool IDs that should be disabled const disabledToolIds = new Set() for (const tool of widgetWithTools.registeredTools ?? []) { if (tool.enabled && tool.disables) { tool.disables.forEach((id) => disabledToolIds.add(id)) } } // Sort tools by order and filter enabled data-only tools, excluding disabled tools const sortedTools = [...(widgetWithTools.registeredTools ?? [])] .filter( (tool) => (tool.type ?? 'data') === 'data' && tool.enabled && !disabledToolIds.has(tool.id), ) .sort((a, b) => a.order - b.order) // Execute pipeline - handle both sync and async tools let transformedData = sourceData for (const tool of sortedTools) { // Check if this execution was cancelled if (activePipelines.get(widgetId) !== currentExecution) { return } try { // Call tool function (may return Promise or direct value) transformedData = await tool.fn(transformedData) } catch (error) { // eslint-disable-next-line no-console console.error(`Tool ${tool.id} failed for widget ${widgetId}:`, error) // Continue with current data to prevent one tool from breaking all } } // Skip store update if neither data nor sourceData changed const widgetAfter = get().widgets[widgetId] if ( widgetAfter && Object.is(widgetAfter.data, transformedData) && Object.is(widgetAfter.sourceData, sourceData) ) { if (activePipelines.get(widgetId) === currentExecution) { activePipelines.delete(widgetId) } return } // Single store update with final transformed data set((state) => { const currentWidget = state.widgets[widgetId] if (!currentWidget) return state return { widgets: { ...state.widgets, [widgetId]: { ...currentWidget, sourceData, data: transformedData, }, }, } }) // Clean up tracking if (activePipelines.get(widgetId) === currentExecution) { activePipelines.delete(widgetId) } }, /** * Executes the config transformation pipeline for a widget. * * Filters to enabled config-type tools (respecting `disables`), sorts by `order`, * and chains their `fn` calls. The transformed config is spread into the widget state. * * **Cancellation:** Newer executions for the same widget automatically cancel * in-progress ones via a version counter. * * **No-op optimization:** Skips the final `set()` when the config object is unchanged * and all its properties already match what's in the store. */ executeConfigPipeline: async (widgetId: string, baseConfig: object) => { const widget = get().widgets[widgetId] if (!widget) return // Cancel any in-progress config pipeline for this widget const currentExecution = (activeConfigPipelines.get(widgetId) ?? 0) + 1 activeConfigPipelines.set(widgetId, currentExecution) // Build set of tool IDs that should be disabled (cross-type disabling works) const disabledToolIds = new Set() for (const tool of widget.registeredTools ?? []) { if (tool.enabled && tool.disables) { tool.disables.forEach((id) => disabledToolIds.add(id)) } } // Filter to config tools only, sort by order const sortedTools = [...(widget.registeredTools ?? [])] .filter( (tool) => tool.type === 'config' && tool.enabled && !disabledToolIds.has(tool.id), ) .sort((a, b) => a.order - b.order) // Chain config tools let transformedConfig: unknown = baseConfig for (const tool of sortedTools) { if (activeConfigPipelines.get(widgetId) !== currentExecution) { return } try { transformedConfig = await tool.fn(transformedConfig) } catch (error) { // eslint-disable-next-line no-console console.error( `Config tool ${tool.id} failed for widget ${widgetId}:`, error, ) } } // Build a patch that only includes properties the pipeline should set. // A property is applied when: // 1. A config tool explicitly changed it (transformedConfig[k] !== baseConfig[k]) // 2. First pipeline run for this widget (no _lastConfig yet) // 3. The widget value still matches what the pipeline last set — meaning the // user hasn't modified it via setWidget, so it's safe to overwrite. // A property is SKIPPED when the widget value differs from _lastConfig, // meaning the user (or a hook) changed it since the last pipeline run. const base = baseConfig as Record const result = transformedConfig as Record const widgetNow = get().widgets[widgetId] as unknown as Record< string, unknown > const lastConfig = widgetNow?._lastConfig as | Record | undefined const patch: Record = {} for (const key of Object.keys(result)) { const toolChanged = !Object.is(result[key], base[key]) const isFirstRun = !lastConfig || !(key in lastConfig) const userUnmodified = !isFirstRun && Object.is(widgetNow[key], lastConfig[key]) if (toolChanged || isFirstRun || userUnmodified) { patch[key] = result[key] } } // Skip store update if every patch value already matches the widget const hasChanges = Object.keys(patch).some( (k) => !Object.is(widgetNow?.[k], patch[k]), ) const configRefChanged = !Object.is(transformedConfig, lastConfig) if (!hasChanges && !configRefChanged) { if (activeConfigPipelines.get(widgetId) === currentExecution) { activeConfigPipelines.delete(widgetId) } return } // Apply the patch and store the pipeline output for next run's comparison set((state) => { const currentWidget = state.widgets[widgetId] if (!currentWidget) return state return { widgets: { ...state.widgets, [widgetId]: { ...currentWidget, ...patch, _lastConfig: transformedConfig, }, }, } }) if (activeConfigPipelines.get(widgetId) === currentExecution) { activeConfigPipelines.delete(widgetId) } }, })) /** * Stable references to store actions, accessible without creating a subscription. * * Use this instead of `useWidgetStore((state) => state.setWidget)` to avoid * unnecessary subscriber evaluations. Actions are stable functions that never * change, so subscribing to them wastes cycles on every store update. * * @example * ```tsx * import { widgetStoreActions } from '@carto/ps-react-ui/widgets' * * const { setWidget, registerTool } = widgetStoreActions * * useEffect(() => { * registerTool(id, { id: 'my-tool', order: 10, enabled: true, fn: (d) => d }) * return () => widgetStoreActions.unregisterTool(id, 'my-tool') * }, [id]) * ``` */ export const widgetStoreActions: WidgetStoreActions = { get setWidget() { return useWidgetStore.getState().setWidget }, get removeWidget() { return useWidgetStore.getState().removeWidget }, get clearWidgets() { return useWidgetStore.getState().clearWidgets }, get getWidget() { return useWidgetStore.getState().getWidget }, get registerTool() { return useWidgetStore.getState().registerTool }, get unregisterTool() { return useWidgetStore.getState().unregisterTool }, get setToolEnabled() { return useWidgetStore.getState().setToolEnabled }, get triggerToolPipeline() { return useWidgetStore.getState().triggerToolPipeline }, get executeToolPipeline() { return useWidgetStore.getState().executeToolPipeline }, get executeConfigPipeline() { return useWidgetStore.getState().executeConfigPipeline }, } as WidgetStoreActions