Skip to content

Commit acb2140

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 fe59f3d commit acb2140

File tree

4 files changed

+45
-44
lines changed

4 files changed

+45
-44
lines changed

src/ComponentModel/Component.php

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,6 @@ abstract class Component implements IComponent
3030
*/
3131
private array $monitors = [];
3232

33-
/** Prevents nested listener execution during refreshMonitors */
34-
private bool $callingListeners = false;
35-
3633

3734
/**
3835
* Finds the closest ancestor of specified type.
@@ -201,33 +198,19 @@ protected function validateParent(IContainer $parent): void
201198
/**
202199
* Refreshes monitors when attaching/detaching from component tree.
203200
* @param ?array<string, true> $missing null = detaching, array = attaching
204-
* @param array<int, array{\Closure, IComponent}> $listeners
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-
}
214-
}
211+
$processed[spl_object_id($this)] = true;
215212

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]; // clear cached object, keep listener registrations
222-
foreach ($callbacks[1] 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
213+
if ($missing !== null) { // attaching
231214
foreach ($this->monitors as $type => [$ancestor, , , $callbacks]) {
232215
if (isset($ancestor)) { // already cached and valid - skip
233216
continue;
@@ -243,7 +226,10 @@ private function refreshMonitors(int $depth, ?array &$missing = null, array &$li
243226
assert($type !== '');
244227
if ($ancestor = $this->lookup($type, throw: false)) {
245228
foreach ($callbacks[0] as $attached) {
246-
$listeners[] = [$attached, $ancestor];
229+
if (!in_array($key = [$attached, spl_object_id($ancestor)], $called, strict: false)) { // ceduplicate: same callback + same object = call once
230+
$attached($ancestor);
231+
$called[] = $key;
232+
}
247233
}
248234
} else {
249235
$missing[$type] = true; // ancestor not found - remember so we don't check again
@@ -254,18 +240,33 @@ private function refreshMonitors(int $depth, ?array &$missing = null, array &$li
254240
}
255241
}
256242

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

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: 4 additions & 4 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

@@ -253,6 +253,6 @@ test('component moved to later-visited container is not reprocessed', function (
253253
// 4. Without $processed tracking, mover would be processed again
254254
$root->addComponent($parent, 'parent');
255255

256-
Assert::same(2, $callCount); // problem: callback must be called exactly once
256+
Assert::same(1, $callCount, 'Callback must be called exactly once');
257257
Assert::same($secondContainer, $mover->getParent());
258258
});

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)