Skip to content

Commit c5a6517

Browse files
committed
added AGENTS.md & DOCS
1 parent b4e82e1 commit c5a6517

4 files changed

Lines changed: 247 additions & 0 deletions

File tree

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
.gitattributes export-ignore
22
.github/ export-ignore
33
.gitignore export-ignore
4+
AGENTS.md export-ignore
45
ncs.* export-ignore
56
phpstan*.neon export-ignore
7+
docs/ export-ignore
68
tests/ export-ignore
79

810
*.php* diff=php

AGENTS.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# To My Agents!
2+
3+
It is my fervent wish that this file guide every AI coding agent working with code in this repository.
4+
5+
## Documentation
6+
7+
Any distilled, agent-facing documentation for this package - how it works
8+
internally and the rationale behind key design decisions - lives in `docs/`.
9+
Consult it before non-trivial changes; it is the source of truth from which the
10+
public manual is distilled.
11+
12+
Five small files, but a genuinely tricky core (tree + monitoring + lookup +
13+
cloning). Almost all the value is in `Component::refreshMonitors` and the cloning
14+
handshake - read `docs/internals.md` before touching them.
15+
16+
## Project Overview
17+
18+
**nette/component-model** is the foundational component architecture for the Nette
19+
Framework: a hierarchical system where components nest inside containers, are
20+
monitored for attach/detach events, and are addressed through a path-based lookup.
21+
Consumed by Forms and Application, not usually used directly.
22+
23+
- **PHP Version**: 8.3 - 8.5
24+
- **Package**: `nette/component-model`
25+
26+
## Essential Commands
27+
28+
```bash
29+
# Run all tests
30+
composer tester # or: vendor/bin/tester tests -s
31+
32+
# Run a single test file
33+
php tests/ComponentModel/Container.getComponents.phpt
34+
35+
# Static analysis (PHPStan level 8)
36+
composer phpstan
37+
```
38+
39+
## Conventions
40+
41+
- Every file starts with `declare(strict_types=1);`; Nette Coding Standard.
42+
- Tests are Nette Tester `.phpt` files; they usually define minimal component
43+
classes (Button, ComponentX) inline. `tests/bootstrap.php` sets up autoloading
44+
and provides a `Notes` helper for recording lifecycle events during a test.
45+
46+
## Working in this repo
47+
48+
- **Monitoring is a single-pass, live-mutation-safe walk with a precise order:**
49+
`attached` fires top-down (ancestor→descendant), `detached` fires bottom-up, and
50+
detach is depth-gated. The `$monitors` array is a per-type cache holding *both*
51+
the `lookup()` result and the registered callbacks. This is the sharp edge -
52+
see `docs/internals.md` and the ADR it references before changing it.
53+
- **Cloning re-homes children via a cross-instance handshake** (`Container::$cloning`
54+
/ `_isCloning()`), not a parameter. Standalone-cloning a child detaches it.
55+
- **A component name cannot contain `-`** (`#^[a-zA-Z0-9_]+$#`) because `-` is the
56+
path separator; `getComponent('a-b-c')` descends into sub-containers.
57+
- **Components cannot be serialized** (`__serialize` throws by design).
58+
- **`getComponent()` return-type inference lives in `nette/phpstan-rules`**
59+
(`GetComponentReturnTypeExtension`), not in phpDoc here - look there for types.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# monitor() notifications fire top-down (ancestor → descendant)
2+
3+
Status: accepted · 2025-12-26 (commit `b4e82e1`) · BC break in 4.0
4+
5+
## Context
6+
7+
Callbacks registered via `monitor()` were historically invoked bottom-up on
8+
attach: descendants first, then their ancestors. The old algorithm also worked
9+
in two phases - it first walked the tree collecting the callbacks to run, and
10+
only then fired them.
11+
12+
That had two flaws:
13+
14+
1. **Callbacks fired even for components no longer in the tree.** If an earlier
15+
callback (typically an ancestor's) had meanwhile removed a component from the
16+
tree, its already-collected callback still ran, referencing an ancestor the
17+
component was not actually attached to.
18+
2. **An ancestor could not "prepare the ground" for its descendants.**
19+
Descendants were notified before the ancestor, so they could not rely on the
20+
ancestor having finished its own initialization against a shared ancestor
21+
(e.g. a Presenter).
22+
23+
## Decision
24+
25+
Notifications fire **top-down** (ancestor to descendant), and the algorithm
26+
works **live**, without a collection phase. The implementation in
27+
`Component::refreshMonitors()` explicitly handles tree mutation during listener
28+
execution:
29+
30+
- a listener may modify the tree (remove itself, siblings, or ancestors);
31+
- before processing a descendant, it is re-checked that it is still attached to
32+
the same parent (`$component->getParent() === $this`), so a removed descendant
33+
is not notified;
34+
- deduplication: the same pair (callback + specific ancestor object) fires
35+
exactly once, even if the component moves during the operation;
36+
- an `spl_object_id()` guard prevents re-entry and infinite loops.
37+
38+
## Detach order is the reverse (descendant → ancestor)
39+
40+
The decision above concerns **attach**. On **detach**, notifications fire the
41+
other way, bottom-up: `refreshMonitors()` first descends into descendants and
42+
only then processes its own `detached` callbacks. A subtree being detached is
43+
torn down in the reverse order it was assembled.
44+
45+
This is deliberate and symmetric to attach. A callback always fires while the
46+
tree above it is still intact: on attach a descendant is guaranteed the ancestor
47+
has finished initializing; on detach it is guaranteed the ancestor is only about
48+
to relinquish its own ancestor, so it can still rely on the whole path upward
49+
during cleanup.
50+
51+
The behavior is pinned by tests: for a tree `A > B > C` where both `B` and `C`
52+
monitor `A`, removing `B` always yields `detached(C)` first, then `detached(B)`.
53+
54+
## Consequences
55+
56+
- An ancestor is notified first and can "stop" a descendant: if it removes the
57+
descendant in its own callback, the descendant's callback never fires.
58+
- The BC break is rare in practice. It only breaks code where two components
59+
coordinate through callbacks and rely on the old order, or code depending on
60+
the old bug (a callback firing after removal from the tree). A survey of
61+
libraries built on the component model found no real breakage.
62+
- A typical `attached` callback only looks upward at its ancestor and handles
63+
itself; it does not depend on sibling/descendant order.
64+
65+
## Rejected alternatives
66+
67+
- **Keep bottom-up order and just add a check for removed components.** This
68+
would fix flaw #1, but an ancestor still could not influence its descendants'
69+
notifications or prepare their environment; the two-phase collection would
70+
remain a source of further edge cases.
71+
- **Configurable order.** Needless API complexity for a scenario that does not
72+
occur in practice.

docs/internals.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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

Comments
 (0)