diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php index 5fb494e52b9c80f63950f088ce29b85c9afe1ca6..ed9270a999e4e0e1fcfc920090d07ad344131604 100644 --- a/core/lib/Drupal/Core/Render/Renderer.php +++ b/core/lib/Drupal/Core/Render/Renderer.php @@ -39,6 +39,13 @@ class Renderer implements RendererInterface { */ protected $elementInfo; + /** + * The stack containing bubbleable rendering metadata. + * + * @var \SplStack|null + */ + protected static $stack; + /** * Constructs a new Renderer. * @@ -65,36 +72,18 @@ public function renderRoot(&$elements) { /** * {@inheritdoc} */ - public function render(&$elements, $is_root_call = FALSE) { - static $stack; - - $update_stack = function(&$element) use (&$stack) { - // The latest frame represents the bubbleable data for the subtree. - $frame = $stack->top(); - // Update the frame, but also update the current element, to ensure it - // contains up-to-date information in case it gets render cached. - $frame->tags = $element['#cache']['tags'] = Cache::mergeTags($element['#cache']['tags'], $frame->tags); - $frame->attached = $element['#attached'] = drupal_merge_attached($element['#attached'], $frame->attached); - $frame->postRenderCache = $element['#post_render_cache'] = NestedArray::mergeDeep($element['#post_render_cache'], $frame->postRenderCache); - }; - - $bubble_stack = function() use (&$stack) { - // If there's only one frame on the stack, then this is the root call, and - // we can't bubble up further. Reset the stack for the next root call. - if ($stack->count() === 1) { - $stack = NULL; - return; - } - - // Merge the current and the parent stack frame. - $current = $stack->pop(); - $parent = $stack->pop(); - $current->tags = Cache::mergeTags($current->tags, $parent->tags); - $current->attached = drupal_merge_attached($current->attached, $parent->attached); - $current->postRenderCache = NestedArray::mergeDeep($current->postRenderCache, $parent->postRenderCache); - $stack->push($current); - }; + public function renderPlain(&$elements) { + $current_stack = self::$stack; + $this->resetStack(); + $output = $this->renderRoot($elements); + self::$stack = $current_stack; + return $output; + } + /** + * {@inheritdoc} + */ + public function render(&$elements, $is_root_call = FALSE) { if (!isset($elements['#access']) && isset($elements['#access_callback'])) { if (is_string($elements['#access_callback']) && strpos($elements['#access_callback'], '::') === FALSE) { $elements['#access_callback'] = $this->controllerResolver->getControllerFromDefinition($elements['#access_callback']); @@ -112,10 +101,10 @@ public function render(&$elements, $is_root_call = FALSE) { return ''; } - if (!isset($stack)) { - $stack = new \SplStack(); + if (!isset(self::$stack)) { + self::$stack = new \SplStack(); } - $stack->push(new RenderStackFrame()); + self::$stack->push(new RenderStackFrame()); // Try to fetch the prerendered element from cache, run any // #post_render_cache callbacks and return the final markup. @@ -132,10 +121,10 @@ public function render(&$elements, $is_root_call = FALSE) { $elements['#markup'] = SafeMarkup::set($elements['#markup']); // The render cache item contains all the bubbleable rendering metadata // for the subtree. - $update_stack($elements); + $this->updateStack($elements); // Render cache hit, so rendering is finished, all necessary info // collected! - $bubble_stack(); + $this->bubbleStack(); return $elements['#markup']; } } @@ -168,7 +157,7 @@ public function render(&$elements, $is_root_call = FALSE) { } catch (\Exception $e) { // Reset stack and re-throw exception. - $stack = NULL; + $this->resetStack(); throw $e; } } @@ -183,9 +172,9 @@ public function render(&$elements, $is_root_call = FALSE) { if (!empty($elements['#printed'])) { // The #printed element contains all the bubbleable rendering metadata for // the subtree. - $update_stack($elements); + $this->updateStack($elements); // #printed, so rendering is finished, all necessary info collected! - $bubble_stack(); + $this->bubbleStack(); return ''; } @@ -311,7 +300,7 @@ public function render(&$elements, $is_root_call = FALSE) { $elements['#markup'] = $prefix . $elements['#children'] . $suffix; // We've rendered this element (and its subtree!), now update the stack. - $update_stack($elements); + $this->updateStack($elements); // Cache the processed element if #cache is set. if (isset($elements['#cache'])) { @@ -328,31 +317,83 @@ public function render(&$elements, $is_root_call = FALSE) { // Only the case of a cache hit when #cache is enabled, is not handled here, // that is handled earlier in Renderer::render(). if ($is_root_call) { - // We've already called $update_stack() earlier, which updated both the + // We've already called ::updateStack() earlier, which updated both the // element and current stack frame. However, // Renderer::processPostRenderCache() can both change the element // further and create and render new child elements, so provide a fresh // stack frame to collect those additions, merge them back to the element, // and then update the current frame to match the modified element state. - $stack->push(new RenderStackFrame()); + self::$stack->push(new RenderStackFrame()); $this->processPostRenderCache($elements); - $post_render_additions = $stack->pop(); + $post_render_additions = self::$stack->pop(); $elements['#cache']['tags'] = Cache::mergeTags($elements['#cache']['tags'], $post_render_additions->tags); $elements['#attached'] = drupal_merge_attached($elements['#attached'], $post_render_additions->attached); $elements['#post_render_cache'] = NestedArray::mergeDeep($elements['#post_render_cache'], $post_render_additions->postRenderCache); - if ($stack->count() !== 1) { + if (self::$stack->count() !== 1) { throw new \LogicException('A stray drupal_render() invocation with $is_root_call = TRUE is causing bubbling of attached assets to break.'); } } // Rendering is finished, all necessary info collected! - $bubble_stack(); + $this->bubbleStack(); $elements['#printed'] = TRUE; $elements['#markup'] = SafeMarkup::set($elements['#markup']); return $elements['#markup']; } + /** + * Resets the renderer service's internal stack (used for bubbling metadata). + * + * Only necessary in very rare/advanced situations, such as when rendering an + * error page if an exception occurred *during* rendering. + */ + protected function resetStack() { + self::$stack = NULL; + } + + /** + * Updates the stack. + * + * @param array &$element + * The element of the render array that has just been rendered. The stack + * frame for this element will be updated with the bubbleable rendering + * metadata of this element. + */ + protected function updateStack(&$element) { + // The latest frame represents the bubbleable metadata for the subtree. + $frame = self::$stack->top(); + // Update the frame, but also update the current element, to ensure it + // contains up-to-date information in case it gets render cached. + $frame->tags = $element['#cache']['tags'] = Cache::mergeTags($element['#cache']['tags'], $frame->tags); + $frame->attached = $element['#attached'] = drupal_merge_attached($element['#attached'], $frame->attached); + $frame->postRenderCache = $element['#post_render_cache'] = NestedArray::mergeDeep($element['#post_render_cache'], $frame->postRenderCache); + } + + /** + * Bubbles the stack. + * + * Whenever another level in the render array has been rendered, the stack + * must be bubbled, to merge its rendering metadata with that of the parent + * element. + */ + protected function bubbleStack() { + // If there's only one frame on the stack, then this is the root call, and + // we can't bubble up further. Reset the stack for the next root call. + if (self::$stack->count() === 1) { + $this->resetStack(); + return; + } + + // Merge the current and the parent stack frame. + $current = self::$stack->pop(); + $parent = self::$stack->pop(); + $current->tags = Cache::mergeTags($current->tags, $parent->tags); + $current->attached = drupal_merge_attached($current->attached, $parent->attached); + $current->postRenderCache = NestedArray::mergeDeep($current->postRenderCache, $parent->postRenderCache); + self::$stack->push($current); + } + /** * Processes #post_render_cache callbacks. * diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php index a6ec2ed16d83f412070b6c70a5c80d870893549b..97f09b6c7fb0cc10c04947e9b4156b8aa6550456 100644 --- a/core/lib/Drupal/Core/Render/RendererInterface.php +++ b/core/lib/Drupal/Core/Render/RendererInterface.php @@ -33,6 +33,34 @@ interface RendererInterface { */ public function renderRoot(&$elements); + /** + * Renders final HTML in situations where no assets are needed. + * + * Calls ::render() in such a way that #post_render_cache callbacks are + * applied. + * + * Useful for e.g. rendering the values of tokens or e-mails, which need a + * render array being turned into a string, but don't need any of the + * bubbleable metadata (the attached assets the cache tags). + * + * Some of these are a relatively common use case and happen *within* a + * ::renderRoot() call, but that is generally highly problematic (and hence an + * exception is thrown when a ::renderRoot() call happens within another + * ::renderRoot() call). However, in this case, we only care about the output, + * not about the bubbling. Hence this uses a separate render stack, to not + * affect the parent ::renderRoot() call. + * + * @param array $elements + * The structured array describing the data to be rendered. + * + * @return string + * The rendered HTML. + * + * @see ::renderRoot() + * @see ::render() + */ + public function renderPlain(&$elements); + /** * Renders HTML given a structured array tree. * diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module index 25fb2749dd145debab6975482849fe8940989a88..538467ac970838740c54beb049410a4fcd9d82a6 100644 --- a/core/modules/filter/filter.module +++ b/core/modules/filter/filter.module @@ -336,7 +336,7 @@ function check_markup($text, $format_id = NULL, $langcode = '', $filter_types_to '#filter_types_to_skip' => $filter_types_to_skip, '#langcode' => $langcode, ); - return drupal_render_root($build); + return \Drupal::service('renderer')->renderPlain($build); } /** diff --git a/core/modules/system/src/Tests/Common/RenderTest.php b/core/modules/system/src/Tests/Common/RenderTest.php index 739e3b5d4e0a27889850e6a3d9f5de2228f6dabd..523ab62823e0b7f9b45ad109e367c85afedf3ed3 100644 --- a/core/modules/system/src/Tests/Common/RenderTest.php +++ b/core/modules/system/src/Tests/Common/RenderTest.php @@ -1197,4 +1197,106 @@ public function testDrupalProcessAttached() { } } + /** + * Tests \Drupal\Core\Render\Renderer::renderPlain(). + */ + public function testRenderPlain() { + $renderer = \Drupal::service('renderer'); + + $complex_child_markup = '<p>Imagine this is a render array for an entity.</p>'; + $parent_markup = '<p>Rendered!</p>'; + + $complex_child_template = [ + '#markup' => $complex_child_markup, + '#attached' => [ + 'library' => [ + 'core/drupal', + ], + ], + '#cache' => [ + 'tags' => [ + 'test:complex_child', + ], + ], + '#post_render_cache' => [ + 'common_test_post_render_cache' => [ + ['foo' => $this->randomString()], + ], + ], + ]; + + // Case 1: ::renderRoot() with nested ::renderRoot(). + $this->pass('Renderer::renderRoot() may not be called inside of another Renderer::renderRoot() call, this must trigger an exception.'); + try { + $complex_child = $complex_child_template; + $page = [ + 'content' => [ + '#pre_render' => [ + function () use ($renderer, $complex_child) { + $renderer->renderRoot($complex_child); + } + ], + '#suffix' => $parent_markup, + ] + ]; + $renderer->renderRoot($page); + $this->fail('No exception triggered.'); + } + catch (\LogicException $e) { + $this->pass('Exception triggered.'); + } + + // Case 2: ::renderRoot() with nested ::render(). + $this->pass('Renderer::render() may be called from anywhere, including from inside of another Renderer::renderRoot() call. Bubbling must be performed.'); + try { + $complex_child = $complex_child_template; + $page = [ + 'content' => [ + '#pre_render' => [ + function ($elements) use ($renderer, $complex_child, $complex_child_markup, $parent_markup) { + $elements['#markup'] = $renderer->render($complex_child); + $this->assertEqual($complex_child_markup, $elements['#markup'], 'Rendered complex child output as expected, without the #post_render_cache callback executed.'); + return $elements; + } + ], + '#suffix' => $parent_markup, + ] + ]; + $output = $renderer->renderRoot($page); + $this->pass('No exception triggered.'); + $this->assertEqual('<p>overridden</p>', $output, 'Rendered output as expected, with the #post_render_cache callback executed.'); + $this->assertTrue(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling performed.'); + $this->assertTrue(in_array('core/drupal', $page['#attached']['library']), 'Asset bubbling performed.'); + } + catch (\LogicException $e) { + $this->fail('Exception triggered.'); + } + + // Case 3: ::renderRoot() with nested ::renderPlain(). + $this->pass('Renderer::renderPlain() may be called from anywhere, including from inside of another Renderer::renderRoot() call.'); + try { + $complex_child = $complex_child_template; + $page = [ + 'content' => [ + '#pre_render' => [ + function ($elements) use ($renderer, $complex_child, $parent_markup) { + $elements['#markup'] = $renderer->renderPlain($complex_child); + $this->assertEqual('<p>overridden</p>', $elements['#markup'], 'Rendered complex child output as expected, with the #post_render_cache callback executed.'); + return $elements; + } + ], + '#suffix' => $parent_markup, + ] + ]; + $output = $renderer->renderRoot($page); + $this->pass('No exception triggered.'); + $this->assertEqual('<p>overridden</p>' . $parent_markup, $output, 'Rendered output as expected, with the #post_render_cache callback executed.'); + $this->assertFalse(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling not performed.'); + $this->assertTrue(empty($page['#attached']), 'Asset bubbling not performed.'); + } + catch (\LogicException $e) { + $this->fail('Exception triggered.'); + } + } + }