diff --git a/lupus_ce_renderer.services.yml b/lupus_ce_renderer.services.yml index 1f395d314aaa214a49494ae6c4dc81e11f15ee9f..a1fa90f26f1ddb8b02bee54c406645eaaa8bcd19 100644 --- a/lupus_ce_renderer.services.yml +++ b/lupus_ce_renderer.services.yml @@ -10,11 +10,6 @@ services: arguments: ['@lupus_ce_renderer.custom_elements_renderer'] tags: - { name: event_subscriber } - lupus_ce_renderer.custom_elements_controller_subscriber: - class: Drupal\lupus_ce_renderer\EventSubscriber\CustomElementsControllerSubscriber - arguments: ['@controller_resolver'] - tags: - - { name: event_subscriber } lupus_ce_renderer.custom_elements_event_subscriber: class: Drupal\lupus_ce_renderer\EventSubscriber\CustomElementsFormatSubscriber tags: @@ -24,8 +19,13 @@ services: arguments: ['@config.factory', '@http_kernel', '@logger.channel.php', '@redirect.destination', '@router.no_access_checks', '@access_manager', '@theme.negotiator'] tags: - { name: event_subscriber } + lupus_ce_renderer.custom_elements_controller_subscriber: + class: Drupal\lupus_ce_renderer\EventSubscriber\CustomElementsControllerSubscriber + arguments: ['@controller_resolver'] + tags: + - { name: event_subscriber } lupus_ce_renderer.custom_elements_route_subscriber: - class: Drupal\lupus_ce_renderer\EventSubscriber\CustomElementsRouteSubscriber + class: Drupal\lupus_ce_renderer\Routing\CustomElementsRouteSubscriber tags: - { name: event_subscriber } lupus_ce_renderer.custom_elements_redirect_response_subscriber: @@ -43,6 +43,10 @@ services: arguments: ['@router.route_provider'] tags: - { name: event_subscriber } + lupus_ce_renderer.early_rendering_controller_wrapper_subscriber: + class: Drupal\lupus_ce_renderer\EventSubscriber\CustomElementsEarlyRenderingControllerWrapperSubscriber + decorates: early_rendering_controller_wrapper_subscriber + arguments: ['@http_kernel.controller.argument_resolver', '@renderer'] cache_context.lupus_ce_renderer_content_format: class: Drupal\lupus_ce_renderer\Cache\Context\ContentFormatCacheContext arguments: ['@request_stack'] diff --git a/src/EventSubscriber/CustomElementsControllerSubscriber.php b/src/EventSubscriber/CustomElementsControllerSubscriber.php index 5e47073624d4c7fa25e505c45b678fcbffb9be4b..3e0ab01af14e376e37b575a9672aae90e9fd2e68 100644 --- a/src/EventSubscriber/CustomElementsControllerSubscriber.php +++ b/src/EventSubscriber/CustomElementsControllerSubscriber.php @@ -2,20 +2,24 @@ namespace Drupal\lupus_ce_renderer\EventSubscriber; -use Symfony\Component\HttpKernel\Event\ControllerEvent; use drunomics\ServiceUtils\Core\Entity\EntityTypeManagerTrait; -use drunomics\ServiceUtils\Symfony\HttpFoundation\RequestStackTrait; use Drupal\Core\Controller\ControllerResolverInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\KernelEvents; /** - * Replaces the controller for entity routes if custom elements is enabled. + * Replaces the controller for entity canonical routes with custom elements. + * + * Runs before EarlyRenderingControllerWrapperSubscriber so the replacement + * controller is properly wrapped with a render context. The decorated + * EarlyRenderingControllerWrapperSubscriber handles CustomElement results. + * + * @see \Drupal\lupus_ce_renderer\EventSubscriber\CustomElementsEarlyRenderingControllerWrapperSubscriber */ class CustomElementsControllerSubscriber implements EventSubscriberInterface { use EntityTypeManagerTrait; - use RequestStackTrait; /** * The controller resolver. @@ -35,7 +39,7 @@ class CustomElementsControllerSubscriber implements EventSubscriberInterface { } /** - * Take over entity view routes. + * Replaces the controller for entity canonical routes. * * @param \Symfony\Component\HttpKernel\Event\ControllerEvent $event * The event. @@ -47,23 +51,13 @@ class CustomElementsControllerSubscriber implements EventSubscriberInterface { $matches = []; if (preg_match('/^entity\.([a-z_]*)\.canonical$/', $event->getRequest()->attributes->get('_route'), $matches) && $this->getEntityTypeManager()->getDefinition($matches[1])) { $controller_definition = '\Drupal\lupus_ce_renderer\Controller\CustomElementsController::entityView'; - $new_controller = $this->controllerResolver->getControllerFromDefinition($controller_definition); - - $event->setController(function () use ($new_controller) { - $response = call_user_func($new_controller); - return $response; - }); + $event->setController($this->controllerResolver->getControllerFromDefinition($controller_definition)); } - else { - // Else handle the request as usually. Modules may implement routes - // and return CustomElement objects as usual, or overtake other routes as - // we did. - // However, EarlyRenderingControllerWrapperSubscriber messes with our - // response, even if it's not a render array. So re-set the original - // controller. - $event->setController($this->controllerResolver->getController($this->getCurrentRequest())); - } - + // Non-entity routes are handled as usual: the early rendering + // controller wrapper subscriber provides the render context, and + // CustomElementsViewSubscriber::onKernelView() converts + // the result (CustomElement, render array, or Response) into the + // appropriate custom elements response. } /** @@ -71,9 +65,11 @@ class CustomElementsControllerSubscriber implements EventSubscriberInterface { */ public static function getSubscribedEvents() { return [ - // Run after EarlyRenderingControllerWrapperSubscriber and generally - // last, so our controller is taken. - KernelEvents::CONTROLLER => ['onKernelController', -300], + // Run before EarlyRenderingControllerWrapperSubscriber (priority 0) so + // it wraps our replacement controller with a render context. Our + // decorated early rendering subscriber handles CustomElement + // results correctly. + KernelEvents::CONTROLLER => ['onKernelController', 10], ]; } diff --git a/src/EventSubscriber/CustomElementsEarlyRenderingControllerWrapperSubscriber.php b/src/EventSubscriber/CustomElementsEarlyRenderingControllerWrapperSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..a5e60567c3164971dba7bd280003982b891f0786 --- /dev/null +++ b/src/EventSubscriber/CustomElementsEarlyRenderingControllerWrapperSubscriber.php @@ -0,0 +1,51 @@ + $result]; + } + return $result; + }; + + $result = parent::wrapControllerExecutionInRenderContext($wrapped_controller, []); + + // Unwrap: restore the original object with merged metadata. + if (is_array($result) && isset($result['#refinable_cacheable_result'])) { + $original = $result['#refinable_cacheable_result']; + $original->addCacheableDependency(BubbleableMetadata::createFromRenderArray($result)); + return $original; + } + + return $result; + } + +} diff --git a/src/EventSubscriber/CustomElementsRouteSubscriber.php b/src/EventSubscriber/CustomElementsRouteSubscriber.php deleted file mode 100644 index 0e359a3d69ad2736a9493afa66f0505460c335b8..0000000000000000000000000000000000000000 --- a/src/EventSubscriber/CustomElementsRouteSubscriber.php +++ /dev/null @@ -1,53 +0,0 @@ -get('entity.node.preview'); - $ce_route = clone $route; - $ce_route->setRequirement('_format', 'custom_elements'); - $ce_route->setDefault('_controller', '\Drupal\lupus_ce_renderer\Controller\CustomElementsController::entityPreview'); - $collection->add('custom_elements.entity.node.preview', $ce_route); - // Provide a CE variant of the node revision route. - $route = $collection->get('entity.node.revision'); - $ce_route = clone $route; - $ce_route->setRequirement('_format', 'custom_elements'); - $ce_route->setDefault('_controller', '\Drupal\lupus_ce_renderer\Controller\CustomElementsController::nodeViewRevision'); - $collection->add('custom_elements.entity.node.revision', $ce_route); - // Provide a CE variant of the node latest version route. - // @see \Drupal\content_moderation\Entity\Routing\EntityModerationRouteProvider - if ($route = $collection->get('entity.node.latest_version')) { - $ce_route = clone $route; - $ce_route->setRequirement('_format', 'custom_elements'); - // Replace '_entity_view' with a custom elements controller. - $ce_route_defaults = $ce_route->getDefaults(); - unset($ce_route_defaults['_entity_view']); - $ce_route->setDefaults($ce_route_defaults); - // Re-use the entity.node.canonical route with latest node as parameter. - $ce_route->setDefault('_controller', '\Drupal\lupus_ce_renderer\Controller\CustomElementsController::entityView'); - $collection->add('custom_elements.entity.node.latest_version', $ce_route); - } - } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array { - $events[RoutingEvents::ALTER] = ['onAlterRoutes', 0]; - return $events; - } - -} diff --git a/src/Routing/CustomElementsRouteSubscriber.php b/src/Routing/CustomElementsRouteSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..5d409b41129a7aab42e42f83f2099e887161aa12 --- /dev/null +++ b/src/Routing/CustomElementsRouteSubscriber.php @@ -0,0 +1,62 @@ +get('entity.node.preview')) { + $ce_route = clone $route; + $ce_route->setRequirement('_format', 'custom_elements'); + $ce_route->setDefault('_controller', '\Drupal\lupus_ce_renderer\Controller\CustomElementsController::entityPreview'); + $collection->add('custom_elements.entity.node.preview', $ce_route); + } + + // Provide a CE variant of the node revision route. + if ($route = $collection->get('entity.node.revision')) { + $ce_route = clone $route; + $ce_route->setRequirement('_format', 'custom_elements'); + $ce_route->setDefault('_controller', '\Drupal\lupus_ce_renderer\Controller\CustomElementsController::nodeViewRevision'); + $collection->add('custom_elements.entity.node.revision', $ce_route); + } + + // Provide a CE variant of the node latest version route. + // @see \Drupal\content_moderation\Entity\Routing\EntityModerationRouteProvider + if ($route = $collection->get('entity.node.latest_version')) { + $ce_route = clone $route; + $ce_route->setRequirement('_format', 'custom_elements'); + $defaults = $ce_route->getDefaults(); + unset($defaults['_entity_view']); + $ce_route->setDefaults($defaults); + $ce_route->setDefault('_controller', '\Drupal\lupus_ce_renderer\Controller\CustomElementsController::entityView'); + $collection->add('custom_elements.entity.node.latest_version', $ce_route); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + $events[RoutingEvents::ALTER] = ['onAlterRoutes', 0]; + return $events; + } + +} diff --git a/tests/modules/lupus_ce_renderer_test/lupus_ce_renderer_test.routing.yml b/tests/modules/lupus_ce_renderer_test/lupus_ce_renderer_test.routing.yml index 9ee50f314fbec15221c32b1fc2aa5d9f7a52cc5c..b0970b3f6ae896932d11914759b40dc60d800a9b 100644 --- a/tests/modules/lupus_ce_renderer_test/lupus_ce_renderer_test.routing.yml +++ b/tests/modules/lupus_ce_renderer_test/lupus_ce_renderer_test.routing.yml @@ -28,3 +28,27 @@ lupus_ce_renderer_test.admin_render_array: _access: 'TRUE' options: _admin_route: TRUE + +lupus_ce_renderer_test.html_response_with_render: + path: '/lupus-ce-renderer-test/html-response-with-render' + defaults: + _controller: '\Drupal\lupus_ce_renderer_test\Controller\TestController::htmlResponseWithRender' + _title: 'HTML response with render() call' + requirements: + _access: 'TRUE' + +lupus_ce_renderer_test.render_array_with_early_rendering: + path: '/lupus-ce-renderer-test/render-array-with-early-rendering' + defaults: + _controller: '\Drupal\lupus_ce_renderer_test\Controller\TestController::renderArrayWithEarlyRendering' + _title: 'Render array with early rendering' + requirements: + _access: 'TRUE' + +lupus_ce_renderer_test.custom_element_with_early_rendering: + path: '/lupus-ce-renderer-test/custom-element-with-early-rendering' + defaults: + _controller: '\Drupal\lupus_ce_renderer_test\Controller\TestController::customElementWithEarlyRendering' + _title: 'CustomElement with early rendering' + requirements: + _access: 'TRUE' diff --git a/tests/modules/lupus_ce_renderer_test/src/Controller/TestController.php b/tests/modules/lupus_ce_renderer_test/src/Controller/TestController.php index a2ced45b25149660491d4a73d4f4e5de5a2f3ce8..59ae436d322c8ecf533349664c49fe39bc6df119 100644 --- a/tests/modules/lupus_ce_renderer_test/src/Controller/TestController.php +++ b/tests/modules/lupus_ce_renderer_test/src/Controller/TestController.php @@ -2,10 +2,35 @@ namespace Drupal\lupus_ce_renderer_test\Controller; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Render\HtmlResponse; +use Drupal\Core\Render\RendererInterface; +use Drupal\custom_elements\CustomElement; +use Symfony\Component\DependencyInjection\ContainerInterface; + /** * Test controller that returns render arrays. */ -class TestController { +class TestController implements ContainerInjectionInterface { + + /** + * Constructs a TestController. + * + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer. + */ + public function __construct( + protected readonly RendererInterface $renderer, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('renderer'), + ); + } /** * Returns a simple render array (not a CustomElement). @@ -19,4 +44,82 @@ class TestController { ]; } + /** + * Returns an HtmlResponse after calling render() internally. + * + * Simulates controllers like Canvas that call render() during execution + * and return an HtmlResponse rather than a render array or CustomElement. + * + * @return \Drupal\Core\Render\HtmlResponse + * An HTML response. + */ + public function htmlResponseWithRender() { + $build = [ + '#markup' => 'Test content from render call', + ]; + // Call render() which requires an active render context. + $output = (string) $this->renderer->render($build); + return new HtmlResponse($output); + } + + /** + * Returns a render array after early rendering that leaks cache metadata. + * + * The early render() call leaks a 'url.query_args' cache context into the + * render context. EarlyRenderingControllerWrapperSubscriber should capture + * this and merge it into the returned render array. + * + * @return array + * A render array. + */ + public function renderArrayWithEarlyRendering() { + // Early rendering: call render() with cache contexts. This leaks + // bubbleable metadata into the early rendering render context. + $early_build = [ + '#markup' => 'Early rendered content', + '#cache' => [ + 'contexts' => ['url.query_args'], + ], + ]; + $this->renderer->render($early_build); + + // Return a render array. The early rendering subscriber should merge + // the leaked cache context + // from the early rendering above into this result. + return [ + '#markup' => 'Main content with early rendering', + ]; + } + + /** + * Returns a CustomElement after early rendering that leaks cache metadata. + * + * The early render() call leaks a 'url.query_args' cache context. The + * decorated EarlyRenderingControllerWrapperSubscriber should capture this + * and merge it into the returned CustomElement via addCacheableDependency(). + * + * @return \Drupal\custom_elements\CustomElement + * A custom element. + */ + public function customElementWithEarlyRendering() { + // Early rendering: call render() with cache contexts. This leaks + // bubbleable metadata into the early rendering render context. + $early_build = [ + '#markup' => 'Early rendered content', + '#cache' => [ + 'contexts' => ['url.query_args'], + ], + ]; + $this->renderer->render($early_build); + + // Return a CustomElement with its own cache context. The decorated + // early rendering subscriber + // should merge the leaked cache context into this result while preserving + // the CustomElement's own cache metadata. + $element = CustomElement::create('drupal-markup') + ->setSlot('default', 'Custom element with early rendering'); + $element->addCacheContexts(['user.roles']); + return $element; + } + } diff --git a/tests/src/Kernel/LupusCeRendererControllerTest.php b/tests/src/Kernel/LupusCeRendererControllerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8a6612365eeb4b7c876799585ec49b75b3a216b4 --- /dev/null +++ b/tests/src/Kernel/LupusCeRendererControllerTest.php @@ -0,0 +1,139 @@ + TRUE]); + + $request = Request::create('/lupus-ce-renderer-test/html-response-with-render'); + $response = $this->container->get('http_kernel')->handle($request); + + // The controller returns an HtmlResponse, which should pass through + // without error. The key assertion is that no LogicException about + // empty render context is thrown. + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('Test content from render call', $response->getContent()); + + // Also test with explicit _format parameter. + $request = Request::create('/lupus-ce-renderer-test/html-response-with-render', 'GET', [ + '_format' => 'custom_elements', + ]); + $response = $this->container->get('http_kernel')->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('Test content from render call', $response->getContent()); + } + + /** + * Tests that early rendering cache metadata is preserved in CE responses. + * + * When a controller does early rendering (calling render() with cache + * contexts), EarlyRenderingControllerWrapperSubscriber captures the leaked + * metadata and merges it into the render array result. The custom elements + * pipeline should then include this metadata in the final JSON response. + */ + public function testEarlyRenderingCacheMetadataPreservedForRenderArray() { + new Settings(Settings::getAll() + ['lupus_ce_renderer_enable' => TRUE]); + + $request = Request::create('/lupus-ce-renderer-test/render-array-with-early-rendering'); + $response = $this->container->get('http_kernel')->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('application/json', $response->headers->get('Content-Type')); + + // Verify the early-rendered cache context was captured and is present + // on the final custom elements response. + $this->assertInstanceOf('Drupal\Core\Cache\CacheableResponseInterface', $response); + $cache_contexts = $response->getCacheableMetadata()->getCacheContexts(); + $this->assertContains('url.query_args', $cache_contexts, 'Cache context from early rendering is preserved in the custom elements response.'); + } + + /** + * Tests early rendering metadata is preserved for CustomElement results. + * + * When a controller returns a CustomElement and does early rendering, the + * decorated EarlyRenderingControllerWrapperSubscriber should merge the + * leaked metadata into the CustomElement via addCacheableDependency(). + * Without the decorator, core would throw a LogicException. + */ + public function testEarlyRenderingCacheMetadataPreservedForCustomElement() { + new Settings(Settings::getAll() + ['lupus_ce_renderer_enable' => TRUE]); + + $request = Request::create('/lupus-ce-renderer-test/custom-element-with-early-rendering'); + $response = $this->container->get('http_kernel')->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('application/json', $response->headers->get('Content-Type')); + + // Verify the response contains the custom element content. + $data = $this->decodeResponse($response); + $this->assertStringContainsString('Custom element with early rendering', $data['content']); + + // Verify the early-rendered cache context was captured and is present + // on the final custom elements response. + $this->assertInstanceOf('Drupal\Core\Cache\CacheableResponseInterface', $response); + $cache_contexts = $response->getCacheableMetadata()->getCacheContexts(); + $this->assertContains('url.query_args', $cache_contexts, 'Cache context from early rendering is preserved when controller returns CustomElement.'); + $this->assertContains('user.roles', $cache_contexts, 'Cache context from CustomElement itself is preserved.'); + } + + /** + * Tests early rendering metadata is preserved for entity canonical routes. + * + * Entity canonical routes have their controller replaced by + * CustomElementsControllerSubscriber. The decorated early rendering + * subscriber should still + * provide a render context and handle any leaked metadata. + */ + public function testEntityRouteEarlyRenderingHasRenderContext() { + $response = $this->request($this->nodePath, ['_format' => 'custom_elements']); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('application/json', $response->headers->get('Content-Type')); + + // Verify the response is cacheable and has cache metadata. + $this->assertInstanceOf('Drupal\Core\Cache\CacheableResponseInterface', $response); + $cache_tags = $response->getCacheableMetadata()->getCacheTags(); + $this->assertContains('node_view', $cache_tags, 'Entity route response has expected cache tags.'); + } + +}