- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
20 KiB
LiveComponents Nested Components System
Comprehensive guide for building nested component hierarchies with parent-child relationships, event bubbling, and state synchronization.
Overview
The Nested Components System enables complex UI compositions through parent-child component relationships. Parents can manage global state while children handle localized behavior, with events bubbling up for coordination.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Parent Component │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Global State Management │ │
│ │ • Manages list of items │ │
│ │ • Provides data to children │ │
│ │ • Handles child events │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ │ │
│ ┌─────────▼────────┐ ┌──────────▼────────┐ │
│ │ Child Component │ │ Child Component │ │
│ │ • Local State │ │ • Local State │ │
│ │ • Dispatch Events│ │ • Dispatch Events│ │
│ └──────────────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Core Concepts
1. Component Hierarchy
ComponentHierarchy Value Object - Represents parent-child relationships:
use App\Framework\LiveComponents\ValueObjects\ComponentHierarchy;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
// Root component (no parent)
$rootHierarchy = ComponentHierarchy::root();
// depth=0, path=[]
// First-level child
$childHierarchy = ComponentHierarchy::fromParent(
parentId: ComponentId::fromString('parent:main'),
childId: ComponentId::fromString('child:1')
);
// depth=1, path=['parent:main', 'child:1']
// Add another level
$grandchildHierarchy = $childHierarchy->withChild(
ComponentId::fromString('grandchild:1')
);
// depth=2, path=['parent:main', 'child:1', 'grandchild:1']
Hierarchy Queries:
$hierarchy->isRoot(); // true if no parent
$hierarchy->isChild(); // true if has parent
$hierarchy->getLevel(); // nesting depth (0, 1, 2, ...)
$hierarchy->isDescendantOf($componentId); // check ancestry
2. NestedComponentManager
Server-Side Hierarchy Management:
use App\Framework\LiveComponents\NestedComponentManager;
$manager = new NestedComponentManager();
// Register root component
$parentId = ComponentId::fromString('todo-list:main');
$manager->registerHierarchy($parentId, ComponentHierarchy::root());
// Register child
$childId = ComponentId::fromString('todo-item:1');
$childHierarchy = ComponentHierarchy::fromParent($parentId, $childId);
$manager->registerHierarchy($childId, $childHierarchy);
// Query hierarchy
$manager->hasChildren($parentId); // true
$manager->getChildIds($parentId); // [ComponentId('todo-item:1')]
$manager->getParentId($childId); // ComponentId('todo-list:main')
$manager->isRoot($parentId); // true
$manager->getDepth($childId); // 1
// Get all ancestors/descendants
$ancestors = $manager->getAncestors($childId); // [parentId]
$descendants = $manager->getDescendants($parentId); // [childId]
// Statistics
$stats = $manager->getStats();
// ['total_components' => 2, 'root_components' => 1, ...]
3. SupportsNesting Interface
Parent components must implement this interface:
use App\Framework\LiveComponents\Contracts\SupportsNesting;
interface SupportsNesting
{
/**
* Get list of child component IDs
*/
public function getChildComponents(): array;
/**
* Handle event from child component
*
* @return bool Return false to stop event bubbling, true to continue
*/
public function onChildEvent(ComponentId $childId, string $eventName, array $payload): bool;
/**
* Validate child component compatibility
*/
public function canHaveChild(ComponentId $childId): bool;
}
4. Event Bubbling
Events flow from child to parent:
┌─────────────────────────┐
│ Child Component │
│ • User clicks button │
│ • Dispatches event │
└───────────┬─────────────┘
│ Event Bubbles Up
▼
┌─────────────────────────┐
│ Parent Component │
│ • Receives event │
│ • Updates state │
│ • Re-renders children │
└─────────────────────────┘
Event Dispatcher:
use App\Framework\LiveComponents\NestedComponentEventDispatcher;
$dispatcher = new NestedComponentEventDispatcher();
// Child dispatches event
$dispatcher->dispatch(
componentId: ComponentId::fromString('todo-item:1'),
eventName: 'todo-completed',
payload: [
'todo_id' => '1',
'completed' => true
]
);
// Check dispatched events
$dispatcher->hasEvents(); // true
$dispatcher->count(); // 1
$events = $dispatcher->getEvents();
Implementation Guide
Creating a Parent Component
1. Implement SupportsNesting:
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Contracts\SupportsNesting;
use App\Framework\LiveComponents\Attributes\LiveComponent;
#[LiveComponent('todo-list')]
final readonly class TodoListComponent implements LiveComponentContract, SupportsNesting
{
private ComponentId $id;
private TodoListState $state;
public function __construct(
ComponentId $id,
?ComponentData $initialData = null,
array $todos = []
) {
$this->id = $id;
$this->state = $initialData
? TodoListState::fromComponentData($initialData)
: new TodoListState(todos: $todos);
}
// LiveComponentContract methods
public function getId(): ComponentId { return $this->id; }
public function getData(): ComponentData { return $this->state->toComponentData(); }
public function getRenderData(): ComponentRenderData { /* ... */ }
// SupportsNesting methods
public function getChildComponents(): array
{
// Return array of child component IDs
$childIds = [];
foreach ($this->state->todos as $todo) {
$childIds[] = "todo-item:{$todo['id']}";
}
return $childIds;
}
public function onChildEvent(ComponentId $childId, string $eventName, array $payload): bool
{
// Handle events from children
match ($eventName) {
'todo-completed' => $this->handleTodoCompleted($payload),
'todo-deleted' => $this->handleTodoDeleted($payload),
default => null
};
return true; // Continue bubbling
}
public function canHaveChild(ComponentId $childId): bool
{
// Only accept TodoItem components
return str_starts_with($childId->name, 'todo-item');
}
private function handleTodoCompleted(array $payload): void
{
$todoId = $payload['todo_id'];
$completed = $payload['completed'];
// Log or trigger side effects
error_log("Todo {$todoId} marked as " . ($completed ? 'completed' : 'active'));
// Note: State updates happen through Actions, not event handlers
// Event handlers are for logging, analytics, side effects
}
}
2. Create Parent State:
final readonly class TodoListState
{
public function __construct(
public array $todos = [],
public string $filter = 'all'
) {}
public static function fromComponentData(ComponentData $data): self
{
$array = $data->toArray();
return new self(
todos: $array['todos'] ?? [],
filter: $array['filter'] ?? 'all'
);
}
public function toComponentData(): ComponentData
{
return ComponentData::fromArray([
'todos' => $this->todos,
'filter' => $this->filter
]);
}
public function withTodoAdded(array $todo): self
{
return new self(
todos: [...$this->todos, $todo],
filter: $this->filter
);
}
// More transformation methods...
}
3. Create Parent Template:
<!-- todo-list.view.php -->
<div class="todo-list">
<!-- Parent UI -->
<h2>My Todos ({total_count})</h2>
<!-- Child Components -->
<for items="todos" as="todo">
<div
data-live-component="todo-item:{todo.id}"
data-parent-component="{component_id}"
data-nesting-depth="1"
>
<!-- TodoItemComponent renders here -->
</div>
</for>
</div>
Creating a Child Component
1. Implement Component with Event Dispatcher:
use App\Framework\LiveComponents\NestedComponentEventDispatcher;
#[LiveComponent('todo-item')]
final readonly class TodoItemComponent implements LiveComponentContract
{
private ComponentId $id;
private TodoItemState $state;
public function __construct(
ComponentId $id,
private NestedComponentEventDispatcher $eventDispatcher,
?ComponentData $initialData = null,
?array $todoData = null
) {
$this->id = $id;
$this->state = $initialData
? TodoItemState::fromComponentData($initialData)
: TodoItemState::fromTodoArray($todoData ?? []);
}
#[Action]
public function toggle(): ComponentData
{
$newState = $this->state->withToggled();
// Dispatch event to parent
$this->eventDispatcher->dispatch(
componentId: $this->id,
eventName: 'todo-completed',
payload: [
'todo_id' => $this->state->id,
'completed' => $newState->completed
]
);
return $newState->toComponentData();
}
#[Action]
public function delete(): ComponentData
{
// Dispatch delete event to parent
$this->eventDispatcher->dispatch(
componentId: $this->id,
eventName: 'todo-deleted',
payload: ['todo_id' => $this->state->id]
);
return $this->state->toComponentData();
}
}
2. Create Child State:
final readonly class TodoItemState
{
public function __construct(
public string $id,
public string $title,
public bool $completed = false,
public int $createdAt = 0
) {}
public static function fromTodoArray(array $todo): self
{
return new self(
id: $todo['id'] ?? '',
title: $todo['title'] ?? '',
completed: $todo['completed'] ?? false,
createdAt: $todo['created_at'] ?? time()
);
}
public function withToggled(): self
{
return new self(
id: $this->id,
title: $this->title,
completed: !$this->completed,
createdAt: $this->createdAt
);
}
}
3. Create Child Template:
<!-- todo-item.view.php -->
<div class="todo-item {completed|then:todo-item--completed}">
<button data-livecomponent-action="toggle">
<if condition="completed">✓</if>
</button>
<div class="todo-item__title">{title}</div>
<button data-livecomponent-action="delete">✕</button>
</div>
Client-Side Integration
Automatic Initialization:
// NestedComponentHandler automatically initializes with LiveComponentManager
import { LiveComponentManager } from './livecomponent/index.js';
// Scans DOM for nested components
const nestedHandler = liveComponentManager.nestedHandler;
// Get hierarchy info
const parentId = nestedHandler.getParentId('todo-item:1'); // 'todo-list:main'
const childIds = nestedHandler.getChildIds('todo-list:main'); // ['todo-item:1', ...]
// Event bubbling
nestedHandler.bubbleEvent('todo-item:1', 'todo-completed', {
todo_id: '1',
completed: true
});
// Statistics
const stats = nestedHandler.getStats();
// { total_components: 5, root_components: 1, max_nesting_depth: 2, ... }
Best Practices
1. State Management
✅ Parent owns the data:
// Parent manages list
private TodoListState $state; // Contains all todos
// Child manages display state only
private TodoItemState $state; // id, title, completed, isEditing
❌ Don't duplicate state:
// Bad: Both parent and child store todo data
// This leads to synchronization issues
2. Event Handling
✅ Use event handlers for side effects:
public function onChildEvent(ComponentId $childId, string $eventName, array $payload): bool
{
// ✅ Logging
error_log("Child event: {$eventName}");
// ✅ Analytics
$this->analytics->track($eventName, $payload);
// ✅ External system updates
$this->cache->invalidate($payload['todo_id']);
return true; // Continue bubbling
}
❌ Don't modify state in event handlers:
// ❌ Bad: Event handlers shouldn't modify component state
// State changes happen through Actions that return new ComponentData
3. Child Compatibility
✅ Validate child types:
public function canHaveChild(ComponentId $childId): bool
{
// Only accept specific component types
return str_starts_with($childId->name, 'todo-item');
}
4. Circular Dependencies
✅ Framework automatically prevents:
// This will throw InvalidArgumentException:
$manager->registerHierarchy($componentId, $hierarchy);
// "Circular dependency detected: Component cannot be its own ancestor"
Performance Considerations
Hierarchy Depth
- Recommended: Max 3-4 levels deep
- Reason: Each level adds overhead for event bubbling
- Alternative: Flatten hierarchy when possible
Event Bubbling
- Cost: O(depth) for each event
- Optimization: Stop bubbling early when not needed
- Pattern: Return
falsefromonChildEvent()to stop
public function onChildEvent(ComponentId $childId, string $eventName, array $payload): bool
{
if ($eventName === 'internal-event') {
// Handle locally, don't bubble further
return false;
}
// Let other events bubble
return true;
}
State Synchronization
- Pattern: Parent as single source of truth
- Benefit: Avoids synchronization bugs
- Trade-off: More re-renders, but simpler logic
Testing
Unit Tests
describe('NestedComponentManager', function () {
it('tracks parent-child relationships', function () {
$manager = new NestedComponentManager();
$parentId = ComponentId::fromString('parent:1');
$childId = ComponentId::fromString('child:1');
$manager->registerHierarchy($parentId, ComponentHierarchy::root());
$manager->registerHierarchy(
$childId,
ComponentHierarchy::fromParent($parentId, $childId)
);
expect($manager->hasChildren($parentId))->toBeTrue();
expect($manager->getParentId($childId))->toEqual($parentId);
});
it('prevents circular dependencies', function () {
$manager = new NestedComponentManager();
$id = ComponentId::fromString('self:1');
expect(fn() => $manager->registerHierarchy(
$id,
ComponentHierarchy::fromParent($id, $id)
))->toThrow(InvalidArgumentException::class);
});
});
Integration Tests
describe('TodoList with nested TodoItems', function () {
it('handles child events', function () {
$todoList = new TodoListComponent(
id: ComponentId::fromString('todo-list:test'),
todos: [
['id' => '1', 'title' => 'Test', 'completed' => false]
]
);
$childId = ComponentId::fromString('todo-item:1');
// Simulate child event
$result = $todoList->onChildEvent(
$childId,
'todo-completed',
['todo_id' => '1', 'completed' => true]
);
expect($result)->toBeTrue(); // Event bubbled successfully
});
});
Troubleshooting
Problem: Children not rendering
Cause: Missing data-parent-component attribute
Solution:
<!-- ✅ Correct -->
<div
data-live-component="child:1"
data-parent-component="parent:main"
data-nesting-depth="1"
>
</div>
Problem: Events not bubbling
Cause: Wrong ComponentId or event name
Solution:
// ✅ Use exact component ID
$this->eventDispatcher->dispatch(
componentId: $this->id, // ✅ Correct: use component's own ID
eventName: 'todo-completed',
payload: [...]
);
Problem: Circular dependency error
Cause: Component trying to be its own ancestor
Solution:
// ❌ Wrong: Same component as parent and child
$hierarchy = ComponentHierarchy::fromParent($sameId, $sameId);
// ✅ Correct: Different components
$hierarchy = ComponentHierarchy::fromParent($parentId, $childId);
Advanced Patterns
Multi-Level Nesting
// Grandparent → Parent → Child
$grandparent = ComponentHierarchy::root();
$parent = ComponentHierarchy::fromParent(
ComponentId::fromString('grandparent:1'),
ComponentId::fromString('parent:1')
);
$child = $parent->withChild(
ComponentId::fromString('child:1')
);
// depth=2, path=['grandparent:1', 'parent:1', 'child:1']
Conditional Children
public function getChildComponents(): array
{
// Only show children if filter matches
$filteredTodos = $this->state->getFilteredTodos();
return array_map(
fn($todo) => "todo-item:{$todo['id']}",
$filteredTodos
);
}
Dynamic Child Addition
#[Action]
public function addTodo(string $title): ComponentData
{
$newTodo = [
'id' => uniqid('todo_', true),
'title' => $title,
'completed' => false
];
// State includes new todo
$newState = $this->state->withTodoAdded($newTodo);
// Framework automatically creates child component
// based on getChildComponents() result
return $newState->toComponentData();
}
Summary
Nested Components enable:
- ✅ Complex UI compositions
- ✅ Parent-child communication via events
- ✅ Hierarchical state management
- ✅ Reusable component patterns
- ✅ Type-safe relationships
Key Classes:
ComponentHierarchy- Relationship value objectNestedComponentManager- Server-side hierarchyNestedComponentHandler- Client-side hierarchyNestedComponentEventDispatcher- Event bubblingSupportsNesting- Parent component interface
Next Steps:
- Implement Slot System for flexible composition
- Add SSE integration for real-time updates
- Explore advanced caching strategies