From 7f59ae047433dd9e9f34dee219ffddd2385064a9 Mon Sep 17 00:00:00 2001 From: OlegAnTo2000 <4942000@gmail.com> Date: Wed, 17 Sep 2025 18:29:24 +0300 Subject: [PATCH 1/2] feat(events): support runtime and closure-based event listeners with priority - Added `runtimeEventMap` to store listeners added at runtime via addEventListener() so they are not overwritten by _initEventMap(). - Introduced `closureEventMap` for closure-based event listeners. - Implemented new method `addEventListenerClosure($event, \Closure $callback, int $priority = 0)` to register inline listeners with configurable priority. - Updated `invokeEvent()` to execute: 1) persistent DB plugins, 2) runtime listeners, 3) closure listeners (sorted by priority, lower values run first). - Added `removeEventListenerClosure()` to remove closure listeners either entirely per event or selectively by priority. - Extended `removeAllEventListener()` to clear runtime and closure maps. This change allows developers to safely attach event listeners in `bootstrap.php` or packages without being lost during eventMap initialization, and to use lightweight closures with fine-grained execution order. --- core/src/Revolution/modX.php | 192 ++++++++++++++++++++++++++++------- 1 file changed, 158 insertions(+), 34 deletions(-) diff --git a/core/src/Revolution/modX.php b/core/src/Revolution/modX.php index 086ffd0448..965320b70d 100644 --- a/core/src/Revolution/modX.php +++ b/core/src/Revolution/modX.php @@ -119,6 +119,14 @@ class modX extends xPDO { * @var array A map of elements registered to specific events. */ public $eventMap= null; + /** + * @var array> Runtime listeners added via addEventListener() + */ + protected $runtimeEventMap = []; + /** + * @var array> Closure-based listeners added via addEventListenerClosure() + */ + protected $closureEventMap = []; /** * @var array A map of already processed Elements. */ @@ -1679,61 +1687,94 @@ public function getRegisteredClientStartupScripts() { * @param array $params Optional params provided to the elements registered with an event. * @return bool|array */ - public function invokeEvent($eventName, array $params= []) { - if (!$eventName) - return false; - if ($this->eventMap === null && $this->context instanceof modContext) + public function invokeEvent($eventName, array $params = []) { + if (!$eventName) return false; + + // Initialize the map from the database if necessary + if ($this->eventMap === null && $this->context instanceof modContext) { $this->_initEventMap($this->context->get('key')); - if (!isset ($this->eventMap[$eventName])) { - //$this->log(modX::LOG_LEVEL_DEBUG,'System event '.$eventName.' was executed but does not exist.'); - return false; } - $results= []; - if (count($this->eventMap[$eventName])) { - $this->event= new modSystemEvent(); - foreach ($this->eventMap[$eventName] as $pluginId => $pluginPropset) { + + $results = []; + + // 1) Build a combined list of plugins: persistent (from the database) + runtime + $persistent = (isset($this->eventMap[$eventName]) && is_array($this->eventMap[$eventName])) + ? $this->eventMap[$eventName] + : []; + + $runtime = (!empty($this->runtimeEventMap[$eventName]) && is_array($this->runtimeEventMap[$eventName])) + ? $this->runtimeEventMap[$eventName] + : []; + + // Combine them so that persistent ones have priority (we do not allow duplicates for the same pluginId) + $combinedPlugins = $persistent + $runtime; + + // 2) Calling plugins (as before), but now using $combinedPlugins + if (!empty($combinedPlugins)) { + $this->event = new modSystemEvent(); + + foreach ($combinedPlugins as $pluginId => $pluginPropset) { /** @var modPlugin $plugin */ - $plugin= null; + $plugin = null; $this->Event = clone $this->event; $this->event->resetEventObject(); - $this->event->name= $eventName; - if (isset ($this->pluginCache[$pluginId])) { - $plugin= $this->newObject(modPlugin::class); + $this->event->name = $eventName; + + if (isset($this->pluginCache[$pluginId])) { + $plugin = $this->newObject(modPlugin::class); $plugin->fromArray($this->pluginCache[$pluginId], '', true, true); $plugin->_processed = false; if ($plugin->get('disabled')) { - $plugin= null; + $plugin = null; } } else { - $plugin= $this->getObject(modPlugin::class, ['id' => intval($pluginId), 'disabled' => '0'], true); + $plugin = $this->getObject(modPlugin::class, ['id' => intval($pluginId), 'disabled' => '0'], true); } + if ($plugin && !$plugin->get('disabled')) { $this->event->plugin =& $plugin; - $this->event->activated= true; - $this->event->activePlugin= $plugin->get('name'); - $this->event->propertySet= (($pspos = strpos($pluginPropset, ':')) >= 1) ? substr($pluginPropset, $pspos + 1) : ''; - - /* merge in plugin properties */ - $eventParams = array_merge($plugin->getProperties(),$params); - - $msg= $plugin->process($eventParams); - $results[]= $this->event->_output; + $this->event->activated = true; + $this->event->activePlugin = $plugin->get('name'); + $this->event->propertySet = (($pspos = strpos($pluginPropset, ':')) >= 1) ? substr($pluginPropset, $pspos + 1) : ''; + + $eventParams = array_merge($plugin->getProperties(), $params); + + $msg = $plugin->process($eventParams); + $results[] = $this->event->_output; + if ($msg && is_string($msg)) { $this->log(modX::LOG_LEVEL_ERROR, '[' . $this->event->name . ']' . $msg); } elseif ($msg === false) { $this->log(modX::LOG_LEVEL_ERROR, '[' . $this->event->name . '] Plugin ' . $plugin->name . ' failed!'); } + $this->event->plugin = null; - $this->event->activePlugin= ''; - $this->event->propertySet= ''; + $this->event->activePlugin = ''; + $this->event->propertySet = ''; if (!$this->event->isPropagatable()) { break; } } } } - return $results; + + if (!empty($this->closureEventMap[$eventName])) { + usort($this->closureEventMap[$eventName], function ($a, $b) { + return $a['priority'] <=> $b['priority']; + }); + + foreach ($this->closureEventMap[$eventName] as $listener) { + try { + $results[] = $listener['callback']($params, $this); + } catch (\Throwable $e) { + $this->log(modX::LOG_LEVEL_ERROR, "[{$eventName}] Closure failed: " . $e->getMessage()); + } + } + } + + return $results ?: false; } + /** * Loads and runs a specific processor. @@ -2062,16 +2103,36 @@ public function logManagerAction($action, $class_key, $item, $userId = null) { */ public function removeEventListener($event, $pluginId = 0) { $removed = false; + + // Remove from persistent map (as before) if (!empty($event) && isset($this->eventMap[$event])) { if (intval($pluginId)) { - unset ($this->eventMap[$event][$pluginId]); + unset($this->eventMap[$event][$pluginId]); + } else { + unset($this->eventMap[$event]); + } + $removed = true; + } + + // Remove from runtime map + if (!empty($event) && isset($this->runtimeEventMap[$event])) { + if (intval($pluginId)) { + unset($this->runtimeEventMap[$event][$pluginId]); } else { - unset ($this->eventMap[$event]); + unset($this->runtimeEventMap[$event]); } $removed = true; } + + // Closures are removed only by event (no point comparison of Closures) + if (!empty($event) && isset($this->closureEventMap[$event]) && !intval($pluginId)) { + unset($this->closureEventMap[$event]); + $removed = true; + } + return $removed; } + /** * Remove all registered events for the current request. @@ -2079,6 +2140,41 @@ public function removeEventListener($event, $pluginId = 0) { public function removeAllEventListener() { unset ($this->eventMap); $this->eventMap= []; + $this->runtimeEventMap = []; + $this->closureEventMap = []; + } + + /** + * Remove all runtime event listeners added via addEventListener() or addEventListenerClosure() + */ + public function removeAllRuntimeEventListeners() { + $this->runtimeEventMap = []; + $this->closureEventMap = []; + } + + /** + * Remove all closure listeners for a specific event (and optionally by priority) + * @param string $event + * @param int|null $priority + * @return boolean + */ + public function removeEventListenerClosure(string $event, ?int $priority = null): bool + { + if (empty($event) || !isset($this->closureEventMap[$event])) { + return false; + } + + if ($priority === null) { + unset($this->closureEventMap[$event]); + return true; + } + + $this->closureEventMap[$event] = array_filter( + $this->closureEventMap[$event], + fn($listener) => $listener['priority'] !== $priority + ); + + return true; } /** @@ -2092,16 +2188,44 @@ public function removeAllEventListener() { public function addEventListener($event, $pluginId, $propertySetName = '') { $added = false; $pluginId = intval($pluginId); + if ($event && $pluginId) { - if (!isset($this->eventMap[$event]) || empty ($this->eventMap[$event])) { - $this->eventMap[$event]= []; + if (!isset($this->runtimeEventMap[$event]) || empty($this->runtimeEventMap[$event])) { + $this->runtimeEventMap[$event] = []; } - $this->eventMap[$event][$pluginId]= $pluginId . (!empty($propertySetName) ? ':' . $propertySetName : ''); + $this->runtimeEventMap[$event][$pluginId] = $pluginId . (!empty($propertySetName) ? ':' . $propertySetName : ''); $added = true; } return $added; } + /** + * Add a closure-based listener for a system event (runtime only). + * + * @param string $event + * @param \Closure $callback function(array $params, modX $modx): mixed + * @param int $priority Lower value = earlier execution (default 10) + * @return bool + */ + public function addEventListenerClosure(string $event, \Closure $callback, int $priority = 0): bool + { + if (!$event || !$callback) { + return false; + } + + if (!isset($this->closureEventMap[$event])) { + $this->closureEventMap[$event] = []; + } + + $this->closureEventMap[$event][] = [ + 'priority' => $priority, + 'callback' => $callback, + ]; + + return true; + } + + /** * Switches the primary Context for the modX instance. * From f61ad21dc65efc23a08abf73d61785393b763f64 Mon Sep 17 00:00:00 2001 From: OlegAnTo2000 <4942000@gmail.com> Date: Wed, 17 Sep 2025 20:04:00 +0300 Subject: [PATCH 2/2] refactor(events): enhance closure listener management and event removal - Changed visibility of `runtimeEventMap` and `closureEventMap` to public for easier access. - Introduced `closureEventSeq` to maintain stable order for closure listeners with the same priority. - Updated closures sorting logic in `invokeEvent()` to consider both priority and sequence. - Enhanced `removeEventListenerClosure()` to allow removal by event name and priority, or clear all listeners. - Modified `addEventListenerClosure()` to support named listeners and optional replacement. --- core/src/Revolution/modX.php | 107 ++++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 27 deletions(-) diff --git a/core/src/Revolution/modX.php b/core/src/Revolution/modX.php index 965320b70d..2687605131 100644 --- a/core/src/Revolution/modX.php +++ b/core/src/Revolution/modX.php @@ -122,11 +122,15 @@ class modX extends xPDO { /** * @var array> Runtime listeners added via addEventListener() */ - protected $runtimeEventMap = []; + public $runtimeEventMap = []; /** * @var array> Closure-based listeners added via addEventListenerClosure() */ - protected $closureEventMap = []; + public $closureEventMap = []; + /** + * @var int Monotonic sequence to keep stable order for same priority + */ + public $closureEventSeq = 0; /** * @var array A map of already processed Elements. */ @@ -1759,9 +1763,13 @@ public function invokeEvent($eventName, array $params = []) { } if (!empty($this->closureEventMap[$eventName])) { - usort($this->closureEventMap[$eventName], function ($a, $b) { - return $a['priority'] <=> $b['priority']; - }); + usort( + $this->closureEventMap[$eventName], + static function (array $a, array $b) { + $byPrio = $a['priority'] <=> $b['priority']; + return $byPrio !== 0 ? $byPrio : ($a['seq'] <=> $b['seq']); + } + ); foreach ($this->closureEventMap[$eventName] as $listener) { try { @@ -2152,31 +2160,61 @@ public function removeAllRuntimeEventListeners() { $this->closureEventMap = []; } - /** - * Remove all closure listeners for a specific event (and optionally by priority) - * @param string $event - * @param int|null $priority - * @return boolean + /** + * Remove closure listeners. + * + * @param ?string $event If set, remove only in this event. + * If null/empty, and $name provided — remove in all events. + * If null/empty, and $name=null — clear entire closureEventMap. + * @param ?int $priority If set, remove only listeners with this priority. + * @param ?string $name If set, remove only listeners with this name. + * @return bool True if something was removed. */ - public function removeEventListenerClosure(string $event, ?int $priority = null): bool + public function removeEventListenerClosure(?string $event = null, ?int $priority = null, ?string $name = null): bool { - if (empty($event) || !isset($this->closureEventMap[$event])) { - return false; - } + $removed = false; - if ($priority === null) { - unset($this->closureEventMap[$event]); + // Ветка 1: полный wipe + if (empty($event) && $name === null && $priority === null) { + $this->closureEventMap = []; return true; } - $this->closureEventMap[$event] = array_filter( - $this->closureEventMap[$event], - fn($listener) => $listener['priority'] !== $priority - ); + // Ветка 2: поиск по всем событиям (если event пустой, а имя или приоритет заданы) + $eventsToCheck = empty($event) ? array_keys($this->closureEventMap) : [$event]; - return true; + foreach ($eventsToCheck as $ev) { + if (!isset($this->closureEventMap[$ev])) { + continue; + } + + $before = count($this->closureEventMap[$ev]); + $this->closureEventMap[$ev] = array_values(array_filter( + $this->closureEventMap[$ev], + static function (array $l) use ($priority, $name) { + if ($priority !== null && (int)$l['priority'] !== $priority) { + return true; // оставляем + } + if ($name !== null && ($l['name'] ?? null) !== $name) { + return true; // оставляем + } + return false; // совпал — удаляем + } + )); + + if (empty($this->closureEventMap[$ev])) { + unset($this->closureEventMap[$ev]); + } + + if ($before !== count($this->closureEventMap[$ev] ?? [])) { + $removed = true; + } + } + + return $removed; } + /** * Add a plugin to the eventMap within the current execution cycle. * @@ -2204,20 +2242,35 @@ public function addEventListener($event, $pluginId, $propertySetName = '') { * * @param string $event * @param \Closure $callback function(array $params, modX $modx): mixed - * @param int $priority Lower value = earlier execution (default 10) + * @param int $priority Lower = earlier execution (default 10) + * @param ?string $name Optional logical name to manage/remove listener + * @param bool $replace If true and $name exists — replace it (default true) * @return bool */ - public function addEventListenerClosure(string $event, \Closure $callback, int $priority = 0): bool - { - if (!$event || !$callback) { - return false; - } + public function addEventListenerClosure( + string $event, + \Closure $callback, + int $priority = 10, + ?string $name = null, + bool $replace = true + ): bool { + if (!$event || !$callback) return false; if (!isset($this->closureEventMap[$event])) { $this->closureEventMap[$event] = []; } + // If a name is provided and replace is true — remove existing with that name + if ($name !== null && $replace) { + $this->closureEventMap[$event] = array_values(array_filter( + $this->closureEventMap[$event], + static fn(array $l) => ($l['name'] ?? null) !== $name + )); + } + $this->closureEventMap[$event][] = [ + 'seq' => ++$this->closureEventSeq, // keep stable order for same priority + 'name' => $name, // can be null 'priority' => $priority, 'callback' => $callback, ];