Skip to content

Commit b4e82e1

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 c07bbee commit b4e82e1

5 files changed

Lines changed: 46 additions & 45 deletions

File tree

phpstan.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ parameters:
1414
- # complex tuple type of $monitors is hard to express precisely for all mutation paths
1515
message: '#^Property Nette\\ComponentModel\\Component\:\:\$monitors .+ does not accept .+\.$#'
1616
identifier: assign.propertyType
17-
count: 5
17+
count: 4
1818
path: src/ComponentModel/Component.php
1919

2020
- # IContainer::getComponent() has optional $throw parameter in implementation

src/ComponentModel/Component.php

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

31-
/** Prevents nested listener execution during refreshMonitors */
32-
private bool $callingListeners = false;
33-
3431

3532
/**
3633
* 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): void, IComponent}> $listeners
201+
* @param array<array{\Closure(IComponent): void, 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)) { // deduplicate: 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, $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)) { // deduplicate: 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
@@ -86,8 +86,8 @@ test('monitor with handlers', function () {
8686
$a = new A;
8787
$a['b'] = $b;
8888
Assert::same([
89-
'ATTACHED(A, C)',
9089
'ATTACHED(A, B)',
90+
'ATTACHED(A, C)',
9191
], $log);
9292

9393
$log = [];

tests/ComponentModel/Component.monitorEdgeCases.phpt

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

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

@@ -102,7 +102,7 @@ test('multiple nested children removed by parent', function () {
102102
$root->addComponent($parent, 'parent');
103103

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

108108
Assert::same(0, count($parent->getComponents()));
@@ -129,7 +129,7 @@ test('child removes sibling before its listener runs', function () {
129129
$root->addComponent($container, 'container');
130130

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

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

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

tests/ComponentModel/Container.clone.monitor.phpt

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

186186
Assert::same([
187-
'ATTACHED(A, C)',
188187
'ATTACHED(A, B)',
188+
'ATTACHED(A, C)',
189189
], $log);
190190

191191
Assert::same([

0 commit comments

Comments
 (0)