|
| 1 | +# ComponentModel internals |
| 2 | + |
| 3 | +Five small files, but a genuinely tricky core — the classic "small package, |
| 4 | +sharp edges". This is one coherent mechanism (tree + monitoring + lookup + |
| 5 | +cloning), so one file. The value is almost entirely in `Component::refreshMonitors` |
| 6 | +and the cloning handshake; the rest of the API is readable from its signatures. |
| 7 | + |
| 8 | +## The `$monitors` array is a triple-purpose, per-type cache |
| 9 | + |
| 10 | +`Component::$monitors` is keyed by **type** — a `class-string` **or `''`** (the |
| 11 | +key used for the "find the root" / null-type case; `lookup()` does `$type ??= ''`) |
| 12 | +— and each entry is a 4-tuple: |
| 13 | + |
| 14 | +``` |
| 15 | +$monitors[$type] = [foundAncestor, depthToAncestor, pathToAncestor, [attachedCbs, detachedCbs]] |
| 16 | +``` |
| 17 | + |
| 18 | +So a single structure holds **both** the cached `lookup()` result (`[0..2]`) **and** |
| 19 | +the registered `monitor()` callbacks (`[3]`). `lookup()` fills the cache lazily and |
| 20 | +walks parents to find the closest ancestor of the type (or the root when the type |
| 21 | +is empty), storing the path and depth. The **depth** is not decoration: detachment |
| 22 | +uses it (below). |
| 23 | + |
| 24 | +## Top-down attach, bottom-up detach — the central invariant |
| 25 | + |
| 26 | +`setParent()` drives `refreshMonitors()`, whose parameter `$missing` doubles as the |
| 27 | +mode switch: **an array means attaching, `null` means detaching.** Within one |
| 28 | +`refreshMonitors` call the order of the three blocks encodes the whole contract: |
| 29 | + |
| 30 | +1. **attach** processes **this node's** monitors (fires `attached`) — *before* |
| 31 | + recursing; |
| 32 | +2. it then recurses into child components; |
| 33 | +3. **detach** processes this node's monitors (fires `detached`) — *after* the |
| 34 | + child recursion. |
| 35 | + |
| 36 | +Therefore **attach notifications fire ancestor→descendant (top-down)** and |
| 37 | +**detach fires descendant→ancestor (bottom-up)** — symmetric, and emergent only |
| 38 | +from that block ordering. (The rationale is the promoted ADR |
| 39 | +`docs/decisions/2025-12-26 top-down-monitor-notifications.md`; this file states the |
| 40 | +*what*, the ADR the *why*.) |
| 41 | + |
| 42 | +Three properties of this live, single-pass algorithm are the traps: |
| 43 | + |
| 44 | +- **No collection phase — listeners may mutate the tree mid-walk.** The recursion |
| 45 | + guards against it: `$processed` (keyed by `spl_object_id`) prevents re-entry, and |
| 46 | + each child is re-checked with **`$component->getParent() === $this`** because a |
| 47 | + previous sibling's listener may already have removed it. |
| 48 | +- **Callbacks are deduplicated by `[callback, spl_object_id($ancestor)]`** (the |
| 49 | + `$called` accumulator), so the same handler fires **once per ancestor object** |
| 50 | + even if reached by multiple routes. |
| 51 | +- **Detach is depth-gated.** Only monitors whose cached ancestor was **deeper than |
| 52 | + the detachment point** (`$inDepth > $depth`) fire `detached` — an ancestor |
| 53 | + shallower than where the subtree was cut is still reachable and must **not** |
| 54 | + notify. This is why `lookup()` caches the depth. |
| 55 | + |
| 56 | +`monitor()` itself fires `attached` **immediately** if the ancestor already exists |
| 57 | +at registration time, and dedups the attach callback on registration. |
| 58 | + |
| 59 | +## Cloning: the container flags itself, children re-home to the clone |
| 60 | + |
| 61 | +Cloning a subtree must re-parent every cloned child onto the cloned container, and |
| 62 | +it is done with a cross-instance handshake rather than a parameter: |
| 63 | + |
| 64 | +- **`Container::__clone`** takes the original container (`reset($components)->getParent()`), |
| 65 | + sets **`$original->cloning = $this`** (the new clone), clones each child, then |
| 66 | + clears the flag. The flag lives on **`Container`** (`$cloning`), read via the |
| 67 | + `@internal final _isCloning()`. |
| 68 | +- **`Component::__clone`** of each child does `$this->parent = $this->parent->_isCloning()` |
| 69 | + — if the old parent is mid-clone it re-homes to the **new** clone; if |
| 70 | + `_isCloning()` returns `null` (the parent is *not* cloning, i.e. this is a |
| 71 | + standalone clone of a child) it **detaches** and refreshes monitors. |
| 72 | + |
| 73 | +So the direction of the wiring is: the parent announces "I am cloning, here is my |
| 74 | +replacement," and each child, as PHP clones it, asks and redirects itself. The |
| 75 | +`cloning` flag is only valid for the duration of the child-clone loop. |
| 76 | + |
| 77 | +Components **cannot be serialized** — `__serialize` throws by design. |
| 78 | + |
| 79 | +## `getComponents()` vs `getComponentTree()`, and why names can't be a flat map |
| 80 | + |
| 81 | +- **`getComponents()` returns immediate children only.** Passing the old recursive |
| 82 | + or filter arguments now throws `DeprecatedException` (checked via |
| 83 | + `func_get_args`). |
| 84 | +- **`getComponentTree()` returns a flattened depth-first `list`** — deliberately a |
| 85 | + list, not a name-keyed map, because **component names are unique only within |
| 86 | + their container, not across the whole tree**, so a keyed flatten would silently |
| 87 | + drop collisions. |
| 88 | +- **Return-type inference for `getComponent()` lives in `nette/phpstan-rules`** |
| 89 | + (`GetComponentReturnTypeExtension` / `ComponentTreeResolver`), not in phpDoc |
| 90 | + here — an agent chasing types should look there. |
| 91 | + |
| 92 | +## Names, paths, and the factory |
| 93 | + |
| 94 | +- **`NameSeparator` is `-`**, and a component name must match `#^[a-zA-Z0-9_]+$#` |
| 95 | + — so a name **cannot contain `-`**, because `-` is the *path* separator. |
| 96 | + `getComponent('a-b-c')` splits on it and descends into sub-containers. |
| 97 | +- **Lazy factory:** `getComponent($name)` auto-creates via a `createComponent<Ucfirst>()` |
| 98 | + method, guarded so the name's case must match (a leading-uppercase `$name` will |
| 99 | + not trigger it) and the reflected method name matches exactly. |
| 100 | +- **`addComponent` is exception-safe:** it registers the child, then calls |
| 101 | + `setParent` inside a `try/catch` that **undoes the registration** if `setParent` |
| 102 | + throws (e.g. a `validateParent` veto). It also walks ancestors to reject circular |
| 103 | + references. |
| 104 | + |
| 105 | +## Navigation map |
| 106 | + |
| 107 | +| Concern | Where | |
| 108 | +|---|---| |
| 109 | +| Per-type lookup+callback cache | `Component::$monitors`, `lookup`, `lookupPath` | |
| 110 | +| Attach/detach ordering & guards | `Component::refreshMonitors` | |
| 111 | +| Register listeners | `Component::monitor`/`unmonitor` | |
| 112 | +| Clone re-homing handshake | `Container::__clone`/`_isCloning`, `Component::__clone` | |
| 113 | +| Immediate vs full tree | `Container::getComponents`/`getComponentTree` | |
| 114 | +| Factory, path descent, add safety | `Container::getComponent`/`createComponent`/`addComponent` | |
0 commit comments