diff --git a/core/includes/theme.inc b/core/includes/theme.inc index ec97db2..3870138 100644 --- a/core/includes/theme.inc +++ b/core/includes/theme.inc @@ -13,8 +13,10 @@ use Drupal\Component\Utility\Html; use Drupal\Component\Render\MarkupInterface; use Drupal\Component\Utility\Unicode; +use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Config\Config; use Drupal\Core\Config\StorageException; +use Drupal\Core\Render\AttachmentsInterface; use Drupal\Core\Render\RenderableInterface; use Drupal\Core\Template\Attribute; use Drupal\Core\Theme\ThemeSettings; @@ -399,6 +401,20 @@ function theme_get_setting($setting_name, $theme = NULL) { * https://www.drupal.org/node/2575065 */ function theme_render_and_autoescape($arg) { + // Bubbles argument's cacheability & attachment metadata if necessary. + if (!($arg instanceof RenderableInterface) && ($arg instanceof CacheableDependencyInterface || $arg instanceof AttachmentsInterface)) { + $arg_bubbleable = []; + if ($arg instanceof CacheableDependencyInterface) { + $arg_bubbleable['#cache']['contexts'] = $arg->getCacheContexts(); + $arg_bubbleable['#cache']['tags'] = $arg->getCacheTags(); + $arg_bubbleable['#cache']['max-age'] = $arg->getCacheMaxAge(); + } + if ($arg instanceof AttachmentsInterface) { + $arg_bubbleable['#attached'] = $arg->getAttachments(); + } + \Drupal::service('renderer')->render($arg_bubbleable); + } + if ($arg instanceof MarkupInterface) { return (string) $arg; } diff --git a/core/lib/Drupal/Core/Template/TwigExtension.php b/core/lib/Drupal/Core/Template/TwigExtension.php index e204ba9..059d1b5 100644 --- a/core/lib/Drupal/Core/Template/TwigExtension.php +++ b/core/lib/Drupal/Core/Template/TwigExtension.php @@ -4,7 +4,9 @@ use Drupal\Component\Utility\Html; use Drupal\Component\Render\MarkupInterface; +use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Datetime\DateFormatterInterface; +use Drupal\Core\Render\AttachmentsInterface; use Drupal\Core\Render\RenderableInterface; use Drupal\Core\Render\RendererInterface; use Drupal\Core\Routing\UrlGeneratorInterface; @@ -410,6 +412,8 @@ public function escapeFilter(\Twig_Environment $env, $arg, $strategy = 'html', $ return NULL; } + $this->bubbleArgMetadata($arg); + // Keep Twig_Markup objects intact to support autoescaping. if ($autoescape && ($arg instanceof \Twig_Markup || $arg instanceof MarkupInterface)) { return $arg; @@ -463,6 +467,42 @@ public function escapeFilter(\Twig_Environment $env, $arg, $strategy = 'html', $ } /** + * Bubbles Twig template argument's cacheability & attachment metadata. + * + * For example: a generated link or generated URL object is passed as a Twig + * template argument, and its bubbleable metadata must be bubbled. + * + * @see \Drupal\Core\GeneratedLink + * @see \Drupal\Core\GeneratedUrl + * + * @param mixed $arg + * A Twig template argument that is about to be printed. + * + * @see \Drupal\Core\Theme\ThemeManager::render() + * @see \Drupal\Core\Render\RendererInterface::render() + */ + protected function bubbleArgMetadata($arg) { + // If it's a renderable, then it'll be up to the generated render array it + // returns to contain the necessary cacheability & attachment metadata. If + // it doesn't implement CacheableDependencyInterface or AttachmentsInterface + // then there is nothing to do here. + if ($arg instanceof RenderableInterface || !($arg instanceof CacheableDependencyInterface || $arg instanceof AttachmentsInterface)) { + return; + } + + $arg_bubbleable = []; + if ($arg instanceof CacheableDependencyInterface) { + $arg_bubbleable['#cache']['contexts'] = $arg->getCacheContexts(); + $arg_bubbleable['#cache']['tags'] = $arg->getCacheTags(); + $arg_bubbleable['#cache']['max-age'] = $arg->getCacheMaxAge(); + } + if ($arg instanceof AttachmentsInterface) { + $arg_bubbleable['#attached'] = $arg->getAttachments(); + } + $this->renderer->render($arg_bubbleable); + } + + /** * Wrapper around render() for twig printed output. * * If an object is passed which does not implement __toString(), @@ -504,6 +544,7 @@ public function renderVar($arg) { } if (is_object($arg)) { + $this->bubbleArgMetadata($arg); if ($arg instanceof RenderableInterface) { $arg = $arg->toRenderable(); } diff --git a/core/tests/Drupal/KernelTests/Core/Theme/ThemeRenderAndAutoescapeTest.php b/core/tests/Drupal/KernelTests/Core/Theme/ThemeRenderAndAutoescapeTest.php index cbbb75a..cc65ffe 100644 --- a/core/tests/Drupal/KernelTests/Core/Theme/ThemeRenderAndAutoescapeTest.php +++ b/core/tests/Drupal/KernelTests/Core/Theme/ThemeRenderAndAutoescapeTest.php @@ -8,6 +8,7 @@ namespace Drupal\KernelTests\Core\Theme; use Drupal\Component\Utility\Html; +use Drupal\Core\GeneratedLink; use Drupal\Core\Link; use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\Markup; @@ -87,6 +88,26 @@ public function testThemeEscapeAndRenderNotPrintable() { theme_render_and_autoescape(new NonPrintable()); } + /** + * Ensure cache metadata is bubbled when using theme_render_and_autoescape(). + */ + public function testBubblingMetadata() { + $link = new GeneratedLink(); + $link->setGeneratedLink(''); + $link->addCacheTags(['foo']); + + $context = new RenderContext(); + // Use a closure here since we need to render with a render context. + $theme_render_and_autoescape = function () use ($link) { + return theme_render_and_autoescape($link); + }; + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); + $output = $renderer->executeInRenderContext($context, $theme_render_and_autoescape); + $this->assertEquals('', $output); + $this->assertEquals(['foo'], $context->pop()->getCacheTags()); + } + } class NonPrintable { } diff --git a/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php b/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php index cb09022..de1ba20 100644 --- a/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php +++ b/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php @@ -7,8 +7,10 @@ namespace Drupal\Tests\Core\Template; +use Drupal\Core\GeneratedLink; use Drupal\Core\Render\RenderableInterface; use Drupal\Core\Render\RendererInterface; +use Drupal\Core\Routing\UrlGeneratorInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Template\Loader\StringLoader; use Drupal\Core\Template\TwigEnvironment; @@ -240,6 +242,45 @@ public function providerTestRenderVar() { return $data; } + /** + * @covers ::escapeFilter + */ + public function testEscapeWithGeneratedLink() { + $renderer = $this->prophesize(RendererInterface::class); + $twig = new \Twig_Environment(NULL, [ + 'debug' => TRUE, + 'cache' => FALSE, + 'autoescape' => 'html', + 'optimizations' => 0, + ] + ); + + $twig_extension = new TwigExtension($renderer->reveal()); + $twig->addExtension($twig_extension->setUrlGenerator($this->prophesize(UrlGeneratorInterface::class)->reveal())); + $link = new GeneratedLink(); + $link->setGeneratedLink(''); + $link->addCacheTags(['foo']); + + $result = $twig_extension->escapeFilter($twig, $link, 'html', NULL, TRUE); + $renderer->render(["#cache" => ["contexts" => [], "tags" => ["foo"], "max-age" => -1], "#attached" => []])->shouldHaveBeenCalled(); + $this->assertEquals('', $result); + } + + /** + * @covers ::renderVar + */ + public function testRenderVarWithGeneratedLink() { + $renderer = $this->prophesize(RendererInterface::class); + $twig_extension = new TwigExtension($renderer->reveal()); + $link = new GeneratedLink(); + $link->setGeneratedLink(''); + $link->addCacheTags(['foo']); + + $result = $twig_extension->renderVar($link); + $renderer->render(["#cache" => ["contexts" => [], "tags" => ["foo"], "max-age" => -1], "#attached" => []])->shouldHaveBeenCalled(); + $this->assertEquals('', $result); + } + } class TwigExtensionTestString {