Skip to content

Commit 59d23a8

Browse files
committed
Component: attached handles are called top-down (ancestor → descendant) (BC break)
Implementation handles tree mutations during listener execution: - Listeners can modify tree (remove self, siblings, parent) - Validity check before processing children - Deduplication prevents calling same listener twice - Reentry guard prevents infinite loops
1 parent fd29ea9 commit 59d23a8

File tree

4 files changed

+49
-45
lines changed

4 files changed

+49
-45
lines changed

src/ComponentModel/Component.php

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,6 @@ abstract class Component implements IComponent
3232
*/
3333
private array $monitors = [];
3434

35-
/** Prevents nested listener execution during refreshMonitors */
36-
private bool $callingListeners = false;
37-
3835

3936
/**
4037
* Finds the closest ancestor of specified type.
@@ -199,35 +196,25 @@ protected function validateParent(IContainer $parent): void
199196

200197

201198
/**
202-
* Refreshes monitors.
203-
* @param ?array<string, true> $missing (array = attaching, null = detaching)
204-
* @param array<int, array{?\Closure, IComponent}> $listeners
199+
* Refreshes monitors when attaching/detaching from component tree.
200+
* @param ?array<string, true> $missing null = detaching, array = attaching
201+
* @param array<array{\Closure, int}> $called deduplication tracking
202+
* @param array<int, true> $processed prevents reentry
205203
*/
206-
private function refreshMonitors(int $depth, ?array &$missing = null, array &$listeners = []): void
204+
private function refreshMonitors(
205+
int $depth,
206+
?array &$missing = null,
207+
array &$called = [],
208+
array &$processed = [],
209+
): void
207210
{
208-
if ($this instanceof IContainer) {
209-
foreach ($this->getComponents() as $component) {
210-
if ($component instanceof self) {
211-
$component->refreshMonitors($depth + 1, $missing, $listeners);
212-
}
213-
}
211+
$componentId = spl_object_id($this);
212+
if (isset($processed[$componentId])) {
213+
return; // Prevent reentry on same component (can happen if listener modifies tree)
214214
}
215+
$processed[$componentId] = true;
215216

216-
if ($missing === null) { // detaching
217-
foreach ($this->monitors as $type => [$ancestor, $inDepth, , $callbacks]) {
218-
if (isset($inDepth) && $inDepth > $depth) { // only process if ancestor was deeper than current detachment point
219-
assert($ancestor !== null);
220-
if ($callbacks) {
221-
$this->monitors[$type] = [null, null, null, $callbacks];
222-
foreach ($callbacks as [, $detached]) {
223-
$listeners[] = [$detached, $ancestor];
224-
}
225-
} else { // no listeners, just cached lookup result - clear it
226-
unset($this->monitors[$type]);
227-
}
228-
}
229-
}
230-
} else { // attaching
217+
if ($missing !== null) { // attaching
231218
foreach ($this->monitors as $type => [$ancestor, , , $callbacks]) {
232219
if (isset($ancestor)) { // already cached and valid - skip
233220
continue;
@@ -243,7 +230,10 @@ private function refreshMonitors(int $depth, ?array &$missing = null, array &$li
243230
assert($type !== '');
244231
if ($ancestor = $this->lookup($type, throw: false)) {
245232
foreach ($callbacks as [$attached]) {
246-
$listeners[] = [$attached, $ancestor];
233+
if ($attached && !in_array($key = [$attached, spl_object_id($ancestor)], $called, false)) {
234+
$attached($ancestor);
235+
$called[] = $key; // Deduplicate: same callback + same object = call once
236+
}
247237
}
248238
} else {
249239
$missing[$type] = true; // ancestor not found - remember so we don't check again
@@ -254,19 +244,33 @@ private function refreshMonitors(int $depth, ?array &$missing = null, array &$li
254244
}
255245
}
256246

257-
if ($depth === 0 && !$this->callingListeners) { // call listeners
258-
$this->callingListeners = true;
259-
try {
260-
$called = [];
261-
foreach ($listeners as [$callback, $component]) {
262-
$key = [$callback, spl_object_id($component)];
263-
if ($callback && !in_array($key, $called, strict: false)) { // deduplicate: same callback + same object = call once
264-
$callback($component);
265-
$called[] = $key;
247+
if ($this instanceof IContainer) {
248+
foreach ($this->getComponents() as $component) {
249+
if ($component instanceof self
250+
&& !isset($processed[spl_object_id($component)]) // component may have been processed already
251+
&& $component->getParent() === $this // may have been removed by previous sibling's listener
252+
) {
253+
$component->refreshMonitors($depth + 1, $missing, $called, $processed);
254+
}
255+
}
256+
}
257+
258+
if ($missing === null) { // detaching
259+
foreach ($this->monitors as $type => [$ancestor, $inDepth, , $callbacks]) {
260+
if (isset($inDepth) && $inDepth > $depth) { // only process if ancestor was deeper than current detachment point
261+
assert($ancestor !== null);
262+
if ($callbacks) {
263+
$this->monitors[$type] = [null, null, null, $callbacks]; // clear cached object, keep listener registrations
264+
foreach ($callbacks as [, $detached]) {
265+
if ($detached && !in_array($key = [$detached, spl_object_id($ancestor)], $called, false)) {
266+
$detached($ancestor);
267+
$called[] = $key; // Deduplicate: same callback + same object = call once
268+
}
269+
}
270+
} else { // no listeners, just cached lookup result - clear it
271+
unset($this->monitors[$type]);
266272
}
267273
}
268-
} finally {
269-
$this->callingListeners = false;
270274
}
271275
}
272276
}

tests/ComponentModel/Component.monitor.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ test('monitor with handlers', function () {
8888
$a = new A;
8989
$a['b'] = $b;
9090
Assert::same([
91-
'ATTACHED(A, C)',
9291
'ATTACHED(A, B)',
92+
'ATTACHED(A, C)',
9393
], $log);
9494

9595
$log = [];

tests/ComponentModel/Component.monitorEdgeCases.phpt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ test('parent removes children before their listeners run', function () {
7979
$root->addComponent($parent, 'parent');
8080

8181
Assert::true($parent->attachedCalled);
82-
Assert::true($child->attachedCalled); // problem: child listener is called although parent removed it first
82+
Assert::false($child->attachedCalled, 'Child listener NOT called - parent removed it first');
8383
Assert::same(0, count($parent->getComponents()));
8484
});
8585

@@ -104,7 +104,7 @@ test('multiple nested children removed by parent', function () {
104104
$root->addComponent($parent, 'parent');
105105

106106
foreach ($children as $i => $child) {
107-
Assert::true($child->attachedCalled); // problem: child listener is called although parent removed it first
107+
Assert::false($child->attachedCalled, "Child $i listener NOT called - parent removed it first");
108108
}
109109

110110
Assert::same(0, count($parent->getComponents()));
@@ -131,7 +131,7 @@ test('child removes sibling before its listener runs', function () {
131131
$root->addComponent($container, 'container');
132132

133133
Assert::true($child1->attachedCalled);
134-
Assert::true($child2->attachedCalled); // problem: child2 listener is called although child1 removed it first
134+
Assert::false($child2->attachedCalled, 'child2 listener NOT called - child1 removed it first');
135135
Assert::same(1, count($container->getComponents()));
136136
});
137137

tests/ComponentModel/Container.clone.monitor.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,8 @@ test('reattach cloned component', function () {
186186
$a['dolly'] = $dolly;
187187

188188
Assert::same([
189-
'ATTACHED(A, C)',
190189
'ATTACHED(A, B)',
190+
'ATTACHED(A, C)',
191191
], $log);
192192

193193
Assert::same([

0 commit comments

Comments
 (0)