Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- 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
718 lines
20 KiB
Markdown
718 lines
20 KiB
Markdown
# 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:
|
|
|
|
```php
|
|
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:**
|
|
```php
|
|
$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:**
|
|
|
|
```php
|
|
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:**
|
|
|
|
```php
|
|
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:**
|
|
```php
|
|
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:**
|
|
|
|
```php
|
|
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:**
|
|
|
|
```php
|
|
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:**
|
|
|
|
```html
|
|
<!-- 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:**
|
|
|
|
```php
|
|
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:**
|
|
|
|
```php
|
|
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:**
|
|
|
|
```html
|
|
<!-- 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:**
|
|
|
|
```javascript
|
|
// 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:**
|
|
```php
|
|
// 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:**
|
|
```php
|
|
// Bad: Both parent and child store todo data
|
|
// This leads to synchronization issues
|
|
```
|
|
|
|
### 2. Event Handling
|
|
|
|
**✅ Use event handlers for side effects:**
|
|
```php
|
|
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:**
|
|
```php
|
|
// ❌ Bad: Event handlers shouldn't modify component state
|
|
// State changes happen through Actions that return new ComponentData
|
|
```
|
|
|
|
### 3. Child Compatibility
|
|
|
|
**✅ Validate child types:**
|
|
```php
|
|
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:**
|
|
```php
|
|
// 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 `false` from `onChildEvent()` to stop
|
|
|
|
```php
|
|
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
|
|
|
|
```php
|
|
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
|
|
|
|
```php
|
|
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:**
|
|
```html
|
|
<!-- ✅ 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:**
|
|
```php
|
|
// ✅ 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:**
|
|
```php
|
|
// ❌ 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
|
|
|
|
```php
|
|
// 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
|
|
|
|
```php
|
|
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
|
|
|
|
```php
|
|
#[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 object
|
|
- `NestedComponentManager` - Server-side hierarchy
|
|
- `NestedComponentHandler` - Client-side hierarchy
|
|
- `NestedComponentEventDispatcher` - Event bubbling
|
|
- `SupportsNesting` - Parent component interface
|
|
|
|
**Next Steps:**
|
|
- Implement Slot System for flexible composition
|
|
- Add SSE integration for real-time updates
|
|
- Explore advanced caching strategies
|