parsedBody?->get('state', []); $initialData = is_array($stateArray) ? $stateArray : null; // Get fragments parameter if present (for partial rendering) $fragmentNames = $request->parsedBody?->get('fragments', []); $fragmentNames = is_array($fragmentNames) ? $fragmentNames : []; // Resolve component with current state (registry will convert array to State VO) $component = $this->componentRegistry->resolve( $action->componentId, $initialData ); // Handle action using LiveComponentHandler (composition) // Returns ComponentUpdate with empty HTML $update = $this->handler->handle( $component, $action->method, $action->params ); // Create new component instance with updated data for rendering $updatedComponent = $this->componentRegistry->resolve( $action->componentId, $update->state->data // Array will be converted to State VO by registry ); // Render fragments or full HTML based on request if (! empty($fragmentNames)) { // Partial rendering: extract requested fragments $fragmentCollection = $this->fragmentRenderer->renderFragments( $updatedComponent, $fragmentNames ); // If fragments found, return them if (! $fragmentCollection->isEmpty()) { return new JsonResult([ 'fragments' => $fragmentCollection->toAssociativeArray(), 'state' => $update->state->toArray(), 'events' => $update->events, ]); } // Fallback to full render if fragments not found } // Full rendering (default or fallback) $html = $this->componentRegistry->render($updatedComponent); // Return ComponentUpdate with rendered HTML return new JsonResult([ 'html' => $html, 'state' => $update->state->toArray(), // ✅ Prevent double-encoding 'events' => $update->events, ]); } #[Route('/live-component/{id}/upload', method: Method::POST)] public function handleUpload(string $id, HttpRequest $request): JsonResult { // Convert string ID to ComponentId $componentId = ComponentId::fromString($id); // Get state from request (as array) $stateArray = $request->parsedBody?->get('state', []); $initialData = is_array($stateArray) ? $stateArray : null; $params = $request->parsedBody?->get('params', []); // Resolve component (registry will convert array to State VO) $component = $this->componentRegistry->resolve( $componentId, $initialData ); // Check if component supports file uploads if (! $component instanceof SupportsFileUpload) { return new JsonResult([ 'success' => false, 'error' => 'Component does not support file uploads', ], Status::BAD_REQUEST); } // Get uploaded file from request $uploadedFiles = $request->files?->all() ?? []; if (empty($uploadedFiles)) { return new JsonResult([ 'success' => false, 'error' => 'No file uploaded', ], Status::BAD_REQUEST); } // Get first uploaded file (support for multiple files can be added later) $file = reset($uploadedFiles); if (! $file instanceof UploadedFile) { return new JsonResult([ 'success' => false, 'error' => 'Invalid file upload', ], Status::BAD_REQUEST); } // Validate file $validationErrors = $component->validateUpload($file); if (! empty($validationErrors)) { return new JsonResult([ 'success' => false, 'errors' => $validationErrors, ], Status::UNPROCESSABLE_ENTITY); } try { // Handle upload through handler (like regular actions) $update = $this->handler->handleUpload( $component, $file, ActionParameters::fromArray(is_array($params) ? $params : []) ); // Create new component instance with updated data for rendering $updatedComponent = $this->componentRegistry->resolve( $componentId, $update->state->data // Array will be converted to State VO ); // Render HTML using ComponentRegistry $html = $this->componentRegistry->render($updatedComponent); // Return success response return new JsonResult([ 'success' => true, 'html' => $html, 'state' => $update->state->toArray(), // ✅ Prevent double-encoding 'events' => $update->events, 'file' => [ 'name' => $file->name, 'size' => $file->size, 'type' => $file->type, ], ]); } catch (\Exception $e) { return new JsonResult([ 'success' => false, 'error' => $e->getMessage(), ], Status::INTERNAL_SERVER_ERROR); } } /** * Handle lazy loading of component * * Renders component HTML on-demand when it enters viewport. * Used by LazyComponentLoader for performance optimization. * * @param string $id Component ID * @param HttpRequest $request HTTP request * @return JsonResult JSON response with component HTML and state */ #[Route('/live-component/{id}/lazy-load', method: Method::GET)] public function handleLazyLoad(string $id, HttpRequest $request): JsonResult { try { $componentId = ComponentId::fromString($id); // Resolve component with initial state (null = component will use its defaults) $component = $this->componentRegistry->resolve( $componentId, initialData: null ); // Render component HTML with wrapper $html = $this->componentRegistry->renderWithWrapper($component); // Get component state $componentState = $component->state; // Get CSRF token for component (each component has its own token) $csrfToken = $this->componentRegistry->generateCsrfToken($componentId); return new JsonResult([ 'success' => true, 'html' => $html, 'state' => $componentState->toArray(), 'csrf_token' => $csrfToken, 'component_id' => $componentId->toString(), ]); } catch (\Exception $e) { return new JsonResult([ 'success' => false, 'error' => $e->getMessage(), 'error_code' => 'LAZY_LOAD_FAILED', ], Status::INTERNAL_SERVER_ERROR); } } /** * Handle component destruction lifecycle hook * * Called by client-side JavaScript when component element is removed from DOM. * Only calls onDestroy() if component implements LifecycleAware interface. */ #[Route('/live-component/{id}/destroy', method: Method::POST)] public function handleDestroy(string $id, HttpRequest $request): JsonResult { try { $componentId = ComponentId::fromString($id); // Get state from request $stateArray = $request->parsedBody?->get('state', []); $initialData = is_array($stateArray) ? $stateArray : null; // Resolve component with current state $component = $this->componentRegistry->resolve( $componentId, $initialData ); // Only call onDestroy() if component implements LifecycleAware if ($component instanceof LifecycleAware) { try { $component->onDestroy(); } catch (\Throwable $e) { // Log but don't fail - component is being destroyed anyway error_log("Lifecycle hook onDestroy() failed for " . get_class($component) . ": " . $e->getMessage()); } } return new JsonResult([ 'success' => true, 'message' => 'Component destroyed', ]); } catch (\Exception $e) { // Even if destroy fails, return success - component is gone from client return new JsonResult([ 'success' => true, 'message' => 'Component destroyed (with errors)', 'error' => $e->getMessage(), ]); } } /** * Handle batch request - multiple component operations in one request * * Request Format: * { * "operations": [ * { * "componentId": "counter:demo", * "method": "increment", * "params": {"amount": 5}, * "fragments": ["counter-display"], * "operationId": "op-1" * }, * { * "componentId": "user-stats:123", * "method": "refresh", * "operationId": "op-2" * } * ] * } * * Response Format: * { * "results": [ * { * "success": true, * "operationId": "op-1", * "fragments": {"counter-display": "5"}, * "state": {"count": 5}, * "events": [] * }, * { * "success": false, * "operationId": "op-2", * "error": "Component not found", * "errorCode": "COMPONENT_NOT_FOUND" * } * ], * "total_operations": 2, * "success_count": 1, * "failure_count": 1 * } * * Features: * - Executes operations sequentially with error isolation * - Per-operation error handling (one failure doesn't stop batch) * - Fragment support for partial updates * - 60-80% fewer HTTP requests for multi-component updates */ #[Route('/live-component/batch', method: Method::POST)] public function handleBatch(HttpRequest $request): JsonResult { try { // Parse batch request $requestData = $request->parsedBody?->toArray() ?? []; $batchRequest = BatchRequest::fromArray($requestData); // Process batch $batchResponse = $this->batchProcessor->process($batchRequest); // Return batch response return new JsonResult($batchResponse->toArray()); } catch (\InvalidArgumentException $e) { // Invalid batch request format return new JsonResult([ 'error' => $e->getMessage(), 'error_code' => 'INVALID_BATCH_REQUEST', ], Status::BAD_REQUEST); } catch (\Throwable $e) { // Unexpected error return new JsonResult([ 'error' => 'Batch processing failed: ' . $e->getMessage(), 'error_code' => 'BATCH_PROCESSING_FAILED', ], Status::INTERNAL_SERVER_ERROR); } } }