Component Model 4.0: top-down and no more magic

2 hours ago by David Grudl  

Component Model is the quietest library in all of Nette. Nobody talks about it, almost nobody uses it directly, and yet it sits underneath every form and every Control in your application. Version 4.0 is largely a cleanup of deprecated API, but it also hides one genuine change in behavior.

So what is Component Model actually for? If you write presenters and forms, you use Component Model every day without realizing it. It defines the basic building blocks: Component (something that has a name and a parent) and Container (a component that can hold other components). Nette\Application\UI\Control, Nette\Forms\Form and Nette\Forms\Container all inherit from them.

getComponents() is back to direct children only

The getComponents() method once took two parameters: getComponents($deep, $filterType). They let you recursively retrieve every component in the subtree and filter them by type along the way. But we've been backing away from these parameters for a while. First we quietly hid them from the signature in 3.x; in 4.0 they're gone entirely and using them throws a DeprecatedException. The method now always returns an array of direct children and nothing more.

To walk the whole subtree there's getComponentTree(). It isn't new in 4.0; it's been around since version 3.1:

// before:
$all = $container->getComponents(true);

// now (but it's not the same thing)
$all = $container->getComponentTree();

But it isn't just a rename — the shape of the result differs. The old getComponents(true) returned an iterator whose keys were component names. getComponentTree() returns a plain array with numeric keys.

Those numeric keys aren't cosmetic. A component's name is unique only within a single container, not across the whole tree, so within a subtree you can easily run into several components sharing the same name at different levels. That's why getComponents(true) couldn't return a name-keyed array and returned the iterator instead, which complicated its implementation and signature. So the aim was to drop the true parameter and offer two simple methods instead.

Filtering by type (the replacement for $filterType) is easy in today's PHP:

$forms = array_filter(
	$container->getComponentTree(),
	fn($c) => $c instanceof Form,
);

No more magic: SmartObject is gone

The Component class no longer uses the Nette\SmartObject trait, which used to guard access to non-existent properties and add magic getters. In practice you won't notice. The nette/application 3.3 and nette/forms 3.3 releases that Component Model 4.0 expects add SmartObject directly to Control and BaseControl respectively. So if you write presenters, forms or your own controls, nothing changes for you. You'll only notice if you extend the base classes from Component Model directly.

Notification order: top-down

The last change is the only one that truly alters behavior, and it concerns monitoring. Monitoring is a key feature of Component Model. A component can say: “let me know when someone attaches me under a presenter”, and only at that moment, once it knows its ancestor, does it add things like signals or persistent parameters. The monitor() method is what you use for that:

$this->monitor(Presenter::class, function (Presenter $presenter): void {
	// now I know which presenter I'm under
});

And this is exactly where the change happened.

When you attach a component to a tree that's already hanging under some ancestor, all the relevant attached callbacks in the subtree fire. The question is: in what order?

Up to version 3.x the callbacks were called bottom-up (from the deepest child to the ancestor). In 4.0 they're called top-down (from the ancestor to the children). It looks like cosmetics, but it's a fix for genuinely impractical behavior.

The old algorithm worked in two phases. First it walked the entire subtree and collected every (callback, ancestor) pair into a list. Only then did it fire the whole list at once. The problem is that the list was a snapshot of the tree taken before anything had run.

But callbacks routinely manipulate the tree. A parent component can, inside its attached, remove or move its children, perform a redirect or destroy itself. And because the children's callbacks had already been collected, they fired even for components that had meanwhile fallen out of the tree. A component received the notification “you are attached under the presenter” at a moment when it had long since left it.

The new algorithm no longer works in two phases. Instead of collecting the callbacks up front and firing them all at once, it walks the tree and at each node calls that node's callbacks immediately, only then descending to the children. Before each descent it verifies that the child is still genuinely a child and that it hasn't already been processed. As a result:

  • The parent is notified before its children. This matches intuition, and also how the capture phase of DOM events works, for instance. Before a child gets its turn, its parent has already had a chance to prepare the ground.
  • The parent can stop a child. When a parent callback removes a child, the child's callback simply isn't called. The tree is walked live, not from a snapshot.
  • Moves and reentrancy are handled. If a callback moves a component into a container that comes up later, the set of already-processed nodes ensures it isn't processed twice. The crude boolean lock from 3.x has been replaced by precise per-object tracking.
  • Deduplication stays. The same callback-plus-specific-ancestor pair is called exactly once, even when several monitors point at the same ancestor.

So this is a change that makes behavior more predictable and fixes the cases where a callback used to fire on a component that was no longer in the tree.

Will it cause a BC break?

This is the only behavioral change, not just an API cleanup, so it deserves an honest answer. I went through a number of libraries built on top of Component Model and found not a single case where the change in order broke anything. The reason is simple: a typical attached callback only looks “up” at the ancestor that exists at that moment and deals with itself. It assumes nothing about whether a sibling's or a child's callback has already run.

In theory, though, it can break, in two situations:

  • You rely on the opposite order. If you have two components that coordinate through attached callbacks, and the child used to write state that the parent read (or vice versa), the new order swaps that around. The parent now runs first and will see a different state than before. This is rare, because it requires deliberate communication between components across the tree through callbacks.
  • You rely on the old buggy behavior. If your code somehow depended on a child's callback firing even after the parent removed it during attachment, that callback now simply won't run.

If you do none of this — and that's the vast majority of cases — the upgrade will have no impact on you from this angle.

How to migrate

For most projects the upgrade is trivial. Check whether any of the following applies to you:

  • rewrite overridden attached() / detached() as monitor($type, $attached, $detached),
  • replace getComponents(true) with getComponentTree(),
  • replace filtering getComponents(false, Foo::class) with array_filter() over getComponents() (or over getComponentTree() if you were filtering recursively),
  • replace magic $component->name and friends with getters,
  • rename the NAME_SEPARATOR constant to NameSeparator,
  • if you override addComponent(), add the : static return type to the signature (it was previously only in phpDoc as @return static).
David Grudl Open-source creator and AI specialist who opens doors to the world of artificial intelligence. His projects including Nette and Texy! power websites you visit daily. He contributes to Uměligence and La Trine while hosting Tech Guys. Through AI workshops, he champions technology that genuinely improves people's lives.