Component Model 4.0: top-down and no more magic
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
attachedcallbacks, 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()asmonitor($type, $attached, $detached), - replace
getComponents(true)withgetComponentTree(), - replace filtering
getComponents(false, Foo::class)witharray_filter()overgetComponents()(or overgetComponentTree()if you were filtering recursively), - replace magic
$component->nameand friends with getters, - rename the
NAME_SEPARATORconstant toNameSeparator, - if you override
addComponent(), add the: staticreturn type to the signature (it was previously only in phpDoc as@return static).
Further reading
- Latte 3.1: When a templating system truly understands HTML
- Nette Utils 4.0: UTF-8, Finder and named arguments
- Nette DI 3.1: transition release
- Nette Assets: Finally unified API for everything from images to Vite
- Latte: One Line and Localization Done for You
- Nette PHP Generator Brings the Power of PHP 8.4
Sign in to submit a comment