import type { Actions, LogicOp } from '../types/roundabout/types.js'; interface ActionContext { rule: string; changedProperty: string; } export async function processActions( vm: TProps & TActions, actions: Actions, onChange: (key: string) => Promise, useInternalRouting: boolean = false ): Promise<() => void> { const vmAny = vm as any; // Store the internal routing flag on VM vmAny.__roundaboutUseInternalRouting = useInternalRouting; // Check for conflicts with compacts checkForCompactConflicts(vmAny, actions); // Track action states for each action const actionStates = new Map(); // Store action states on VM for internal routing access Object.defineProperty(vmAny, '__roundaboutActionStates', { value: actionStates, enumerable: false, writable: false, configurable: true }); // Register reactions for each action for (const [actionKey, actionConfig] of Object.entries(actions)) { const state = await setupAction(vm, actionKey, actionConfig as LogicOp, actionStates); actionStates.set(actionKey, state); } // Initial evaluation - check all actions on startup (use normal execution, not internal routing) for (const [actionKey, state] of actionStates.entries()) { await evaluateAndExecuteAction(vm, actionKey, state, '__init__'); } // Return cleanup function return () => { actionStates.clear(); delete vmAny.__roundaboutActionStates; delete vmAny.__roundaboutUseInternalRouting; }; } interface ActionState { config: LogicOp; monitoredProps: Set; lastConditionsMet: boolean; pendingTimeout?: any; evaluationLock?: Promise; } function checkForCompactConflicts(vm: any, actions: Actions): void { // Check if any action method conflicts with compact-invoked methods const compactInvokedMethods = new Set(); // Extract methods called by compacts from __roundaboutReactions if (vm.__roundaboutCompactMethods) { vm.__roundaboutCompactMethods.forEach((method: string) => { compactInvokedMethods.add(method); }); } // Check for conflicts — action key IS the method name for (const actionKey of Object.keys(actions)) { if (compactInvokedMethods.has(actionKey)) { throw new Error( `Conflict detected: Method "${actionKey}" is invoked by both a compact and an action. ` + `This creates ambiguity and is not allowed.` ); } } } async function setupAction( vm: TProps & TActions, actionKey: string, config: LogicOp, actionStates: Map ): Promise { const vmAny = vm as any; // Infer properties to monitor from conditions const monitoredProps = inferMonitoredProperties(config); // Create action state const state: ActionState = { config, monitoredProps, lastConditionsMet: false }; // Register reactions for monitored properties if (!vmAny.__roundaboutReactions) { vmAny.__roundaboutReactions = new Map(); } for (const prop of monitoredProps) { if (!vmAny.__roundaboutReactions.has(prop)) { vmAny.__roundaboutReactions.set(prop, []); } const reactionFn = async (value: any) => { await evaluateAndExecuteAction(vm, actionKey, state, prop); }; vmAny.__roundaboutReactions.get(prop).push(reactionFn); } return state; } function inferMonitoredProperties(config: LogicOp): Set { const props = new Set(); const addProps = (value: any) => { if (typeof value === 'string') { props.add(value); } else if (Array.isArray(value)) { value.forEach(p => props.add(p)); } }; if (config.ifAllOf) addProps(config.ifAllOf); if (config.ifKeyIn) addProps(config.ifKeyIn); if (config.ifNoneOf) addProps(config.ifNoneOf); if (config.ifEquals) addProps(config.ifEquals); if (config.ifAtLeastOneOf) addProps(config.ifAtLeastOneOf); if (config.ifNotAllOf) addProps(config.ifNotAllOf); return props; } async function evaluateAndExecuteAction( vm: TProps & TActions, actionKey: string, state: ActionState, changedProperty: string ): Promise { const vmAny = vm as any; // Skip if actions are disabled (during internal routing event dispatch) if (vmAny.__roundaboutDisableActions) { return; } // Serialize evaluations of the same action to prevent duplicate firings // when multiple monitored properties change in the same microtask. while (state.evaluationLock) { await state.evaluationLock; } let resolve!: () => void; state.evaluationLock = new Promise(r => { resolve = r; }); try { const config = state.config; // Clear any pending timeout if (state.pendingTimeout) { clearTimeout(state.pendingTimeout); state.pendingTimeout = undefined; } // Check if conditions are met const conditionsMet = evaluateConditions(vm, config); if (config.debug) { console.log(`[Action: ${actionKey}] Conditions evaluated:`, { conditionsMet, changedProperty, lastConditionsMet: state.lastConditionsMet }); } // Determine if we should execute let shouldExecute = false; if (config.ifKeyIn) { const ifKeyInArr = Array.isArray(config.ifKeyIn) ? config.ifKeyIn : [config.ifKeyIn]; const changedIsInKeyIn = ifKeyInArr.includes(changedProperty); // When ifKeyIn is combined with other conditions (ifAllOf, ifNoneOf, etc.), // only fire when the changed property is in the ifKeyIn list. // When ifKeyIn is used alone, fire on any monitored property change. const hasOtherConditions = config.ifAllOf || config.ifNoneOf || config.ifEquals || config.ifAtLeastOneOf || config.ifNotAllOf; if (hasOtherConditions) { shouldExecute = conditionsMet && changedIsInKeyIn; } else { // Standalone ifKeyIn: fire every time a monitored property changes shouldExecute = conditionsMet; } } else { // For other conditions, execute only on transition to "all conditions met" shouldExecute = conditionsMet && !state.lastConditionsMet; } state.lastConditionsMet = conditionsMet; if (!shouldExecute) { return; } // Apply delay if specified const delay = config.delay || 0; if (delay > 0) { state.pendingTimeout = setTimeout(async () => { await executeAction(vm, actionKey, config, changedProperty); }, delay); } else { await executeAction(vm, actionKey, config, changedProperty); } } finally { state.evaluationLock = undefined; resolve(); } } /** * Evaluate and execute action using internal routing optimization */ async function evaluateAndExecuteActionWithInternalRouting( vm: TProps & TActions, actionKey: string, state: ActionState, changedProperty: string ): Promise { const vmAny = vm as any; const config = state.config; // Clear any pending timeout if (state.pendingTimeout) { clearTimeout(state.pendingTimeout); state.pendingTimeout = undefined; } // Check if conditions are met const conditionsMet = evaluateConditions(vm, config); if (config.debug) { console.log(`[Action: ${actionKey}] Conditions evaluated:`, { conditionsMet, changedProperty, lastConditionsMet: state.lastConditionsMet }); } // Determine if we should execute let shouldExecute = false; if (config.ifKeyIn) { // For ifKeyIn, execute every time a monitored property changes shouldExecute = conditionsMet; } else { // For other conditions, execute only on transition to "all conditions met" shouldExecute = conditionsMet && !state.lastConditionsMet; } state.lastConditionsMet = conditionsMet; if (!shouldExecute) { return; } // Apply delay if specified const delay = config.delay || 0; if (delay > 0) { state.pendingTimeout = setTimeout(async () => { await executeActionWithInternalRouting(vm, actionKey, config, changedProperty); }, delay); } else { await executeActionWithInternalRouting(vm, actionKey, config, changedProperty); } } /** * Execute action using internal routing */ async function executeActionWithInternalRouting( vm: TProps & TActions, actionKey: string, config: LogicOp, changedProperty: string ): Promise { const vmAny = vm as any; // Action key IS the method name const methodName = actionKey; const method: Function | undefined = vmAny[actionKey]; if (typeof method !== 'function') { console.error(`Action method "${methodName}" not found on view model`); return; } // Create context const context: ActionContext = { rule: actionKey, changedProperty }; if (config.debug) { console.log(`[Action: ${actionKey}] Executing method "${methodName}"`, context); } // Call the method let result: any; try { result = method.call(vm, vm, context); // Check if result is a promise (async method) if (result && typeof result.then === 'function') { result = await result; } } catch (error) { console.error(`Error executing action "${actionKey}":`, error); return; } // Merge result back if it's an object - use internal routing if (result && typeof result === 'object' && !Array.isArray(result)) { await processActionResult(vm, result, config.debug); } } function evaluateConditions( vm: TProps & TActions, config: LogicOp ): boolean { const vmAny = vm as any; // All conditions must be true (AND logic) // ifAllOf: All specified properties must be truthy if (config.ifAllOf) { const props = Array.isArray(config.ifAllOf) ? config.ifAllOf : [config.ifAllOf]; if (!props.every(p => !!vmAny[p])) { return false; } } // ifKeyIn: At least one property must have changed (handled by caller) // This is always true if we're being called if (config.ifKeyIn) { // Just check that at least one is defined const props = Array.isArray(config.ifKeyIn) ? config.ifKeyIn : [config.ifKeyIn]; if (!props.some(p => vmAny[p] !== undefined)) { return false; } } // ifNoneOf: None of the specified properties should be truthy if (config.ifNoneOf) { const props = Array.isArray(config.ifNoneOf) ? config.ifNoneOf : [config.ifNoneOf]; if (props.some(p => !!vmAny[p])) { return false; } } // ifEquals: All specified properties must have equal values if (config.ifEquals && Array.isArray(config.ifEquals) && config.ifEquals.length > 1) { const firstValue = vmAny[config.ifEquals[0]]; if (!config.ifEquals.every(p => vmAny[p] === firstValue)) { return false; } } // ifAtLeastOneOf: At least one property must be truthy if (config.ifAtLeastOneOf) { const props = Array.isArray(config.ifAtLeastOneOf) ? config.ifAtLeastOneOf : [config.ifAtLeastOneOf]; if (!props.some(p => !!vmAny[p])) { return false; } } // ifNotAllOf: Not all properties should be truthy (at least one must be falsy) if (config.ifNotAllOf) { const props = Array.isArray(config.ifNotAllOf) ? config.ifNotAllOf : [config.ifNotAllOf]; if (props.every(p => !!vmAny[p])) { return false; } } return true; } async function executeAction( vm: TProps & TActions, actionKey: string, config: LogicOp, changedProperty: string ): Promise { const vmAny = vm as any; // Action key IS the method name const methodName = actionKey; const method: Function | undefined = vmAny[actionKey]; if (typeof method !== 'function') { console.error(`Action method "${methodName}" not found on view model`); return; } // Create context const context: ActionContext = { rule: actionKey, changedProperty }; if (config.debug) { console.log(`[Action: ${actionKey}] Executing method "${methodName}"`, context); } // Call the method let result: any; try { result = method.call(vm, vm, context); // Check if result is a promise (async method) if (result && typeof result.then === 'function') { result = await result; } } catch (error) { console.error(`Error executing action "${actionKey}":`, error); return; } // Merge result back if it's an object if (result && typeof result === 'object' && !Array.isArray(result)) { // Check if internal routing is enabled const useInternalRouting = vmAny.__roundaboutUseInternalRouting !== false; if (useInternalRouting) { // Use internal routing optimization await processActionResult(vm, result, config.debug); } else { // Use traditional approach with assignGingerly const { assignGingerly } = await import('assign-gingerly/assignGingerly.js'); await assignGingerly(vm, result, vmAny.__roundaboutAssignOptions); } } } /** * Process action result using internal routing optimization * Instead of setting properties via setters (which fire events immediately), * we batch changes, evaluate affected actions, and fire events at the end */ async function processActionResult( vm: TProps & TActions, result: Partial, debug?: boolean ): Promise { const vmAny = vm as any; // Import covert property functions const { covertlySetProperty, covertlyGetProperty } = await import('../utils/PropagatorSetup.js'); // Initialize the change bus const changeBus = new Map(); // Add initial changes to the bus for (const [key, value] of Object.entries(result)) { changeBus.set(key, value); } // Track which properties have been processed to avoid infinite loops const processedInThisCycle = new Set(); const maxIterations = 100; // Safety limit let iterations = 0; // Process the bus until it's empty while (changeBus.size > 0 && iterations < maxIterations) { iterations++; // Get current batch of changes const currentBatch = new Map(changeBus); changeBus.clear(); if (debug) { console.log(`[Internal Routing] Iteration ${iterations}, processing ${currentBatch.size} changes:`, Array.from(currentBatch.keys())); } // Apply changes covertly (without firing events) for (const [key, value] of currentBatch) { await covertlySetProperty(vm, key, value); processedInThisCycle.add(key); } // Find all actions that might be affected by these changes const affectedActions = findAffectedActions(vmAny, currentBatch); if (debug && affectedActions.size > 0) { console.log(`[Internal Routing] Affected actions:`, Array.from(affectedActions.keys())); } // Evaluate and execute affected actions for (const [actionKey, state] of affectedActions) { // Get the first changed property from current batch that affects this action const changedProp = Array.from(currentBatch.keys()) .find(key => state.monitoredProps.has(key)) || '__batch__'; // Check if conditions are met const conditionsMet = evaluateConditions(vm, state.config); // Determine if we should execute let shouldExecute = false; if (state.config.ifKeyIn) { // For ifKeyIn, execute if conditions met shouldExecute = conditionsMet; } else { // For other conditions, execute only on transition shouldExecute = conditionsMet && !state.lastConditionsMet; } state.lastConditionsMet = conditionsMet; if (shouldExecute) { if (debug) { console.log(`[Internal Routing] Executing action: ${actionKey}`); } // Execute the action const actionResult = await executeActionForInternalRouting( vm, actionKey, state.config, changedProp ); // Add any new changes to the bus if (actionResult && typeof actionResult === 'object' && !Array.isArray(actionResult)) { for (const [key, value] of Object.entries(actionResult)) { changeBus.set(key, value); } } } } } if (iterations >= maxIterations) { console.warn('[Internal Routing] Max iterations reached, possible infinite loop'); } // Disable action routing temporarily while we fire events // This prevents the events from triggering actions again since we already processed them vmAny.__roundaboutDisableActions = true; try { // Now fire events for all properties that changed const propagator = vmAny.propagator; if (propagator) { for (const prop of processedInThisCycle) { const newValue = covertlyGetProperty(vm, prop); if (debug) { console.log(`[Internal Routing] Firing event for: ${prop} = ${newValue}`); } const { PropertyChangeEvent } = await import('../core/Events.js'); propagator.dispatchEvent(new PropertyChangeEvent(prop, undefined, newValue)); } } } finally { // Re-enable action routing vmAny.__roundaboutDisableActions = false; } } /** * Execute action and return result without processing it * Used during internal routing to collect changes */ async function executeActionForInternalRouting( vm: TProps & TActions, actionKey: string, config: LogicOp, changedProperty: string ): Promise { const vmAny = vm as any; // Action key IS the method name const methodName = actionKey; const method: Function | undefined = vmAny[actionKey]; if (typeof method !== 'function') { console.error(`Action method "${methodName}" not found on view model`); return undefined; } // Create context const context: ActionContext = { rule: actionKey, changedProperty }; // Call the method try { let result = method.call(vm, vm, context); // Check if result is a promise (async method) if (result && typeof result.then === 'function') { result = await result; } return result; } catch (error) { console.error(`Error executing action "${actionKey}":`, error); return undefined; } } /** * Find all actions that monitor any of the changed properties */ function findAffectedActions(vm: any, changedProps: Map): Map { const affected = new Map(); // Get all action states if (!vm.__roundaboutActionStates) { return affected; } const actionStates = vm.__roundaboutActionStates as Map; for (const [actionKey, state] of actionStates) { // Check if any changed property is monitored by this action for (const changedProp of changedProps.keys()) { if (state.monitoredProps.has(changedProp)) { affected.set(actionKey, state); break; } } } return affected; }