diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php index fc9b6ed..5d799dc 100644 --- a/core/lib/Drupal/Core/Render/Renderer.php +++ b/core/lib/Drupal/Core/Render/Renderer.php @@ -197,7 +197,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) { // element is rendered into the final text. if (isset($elements['#pre_render'])) { foreach ($elements['#pre_render'] as $callable) { - if (is_string($callable) && strpos($callable, '::') === FALSE) { + if (is_string($callable) && strpos($callable, '::') !== FALSE) { $callable = $this->controllerResolver->getControllerFromDefinition($callable); } $elements = call_user_func($callable, $elements); @@ -369,7 +369,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) { $this->processPostRenderCache($elements); $post_render_additions = static::$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['#attached'] = $this->mergeAttached($elements['#attached'], $post_render_additions->attached); $elements['#post_render_cache'] = $post_render_additions->postRenderCache; } while (!empty($elements['#post_render_cache'])); if (static::$stack->count() !== 1) { @@ -457,7 +457,7 @@ protected function processPostRenderCache(array &$elements) { // Call all #post_render_cache callbacks, passing the provided context. foreach (array_keys($elements['#post_render_cache']) as $callback) { - if (strpos($callback, '::') === FALSE) { + if (strpos($callback, '::') !== FALSE) { $callable = $this->controllerResolver->getControllerFromDefinition($callback); } else { @@ -493,7 +493,7 @@ protected function cacheGet(array $elements) { } $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render'; - if (!empty($cid) && $cache = $this->cacheFactory->get($bin)->get($cid)) { + if (!empty($cid) && ($cache_bin = $this->cacheFactory->get($bin)) && $cache = $cache_bin->get($cid)) { $cached_element = $cache->data; // Return the cached element. return $cached_element; diff --git a/core/modules/system/src/Tests/Common/RenderTest.php b/core/modules/system/src/Tests/Common/RenderTest.php index 0360681..6ba8fc4 100644 --- a/core/modules/system/src/Tests/Common/RenderTest.php +++ b/core/modules/system/src/Tests/Common/RenderTest.php @@ -28,49 +28,6 @@ class RenderTest extends KernelTestBase { public static $modules = array('system', 'common_test'); /** - * Tests #attached functionality in children elements. - */ - function testDrupalRenderChildrenAttached() { - // The cache system is turned off for POST requests. - $request_method = \Drupal::request()->getMethod(); - \Drupal::request()->setMethod('GET'); - - // Create an element with a child and subchild. Each element loads a - // different library using #attached. - $element = array( - '#type' => 'container', - '#cache' => array( - 'keys' => array('simpletest', 'drupal_render', 'children_attached'), - ), - '#attached' => ['library' => ['test/parent']], - '#title' => 'Parent', - ); - $element['child'] = array( - '#type' => 'container', - '#attached' => ['library' => ['test/child']], - '#title' => 'Child', - ); - $element['child']['subchild'] = array( - '#attached' => ['library' => ['test/subchild']], - '#markup' => 'Subchild', - ); - - // Render the element and verify the presence of #attached JavaScript. - drupal_render($element); - $expected_libraries = ['test/parent', 'test/child', 'test/subchild']; - $this->assertEqual($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.'); - - // Load the element from cache and verify the presence of the #attached - // JavaScript. - $element = array('#cache' => array('keys' => array('simpletest', 'drupal_render', 'children_attached'))); - $this->assertTrue(strlen(drupal_render($element)) > 0, 'The element was retrieved from cache.'); - $this->assertEqual($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.'); - - // Restore the previous request method. - \Drupal::request()->setMethod($request_method); - } - - /** * Tests theme preprocess functions being able to attach assets. */ function testDrupalRenderThemePreprocessAttached() { @@ -96,148 +53,6 @@ function testDrupalRenderThemePreprocessAttached() { } /** - * Tests caching of an empty render item. - */ - function testDrupalRenderCache() { - // The cache system is turned off for POST requests. - $request_method = \Drupal::request()->getMethod(); - \Drupal::request()->setMethod('GET'); - - // Create an empty element. - $test_element = array( - '#cache' => array( - 'cid' => 'render_cache_test', - 'tags' => array('render_cache_tag'), - ), - '#markup' => '', - 'child' => array( - '#cache' => array( - 'cid' => 'render_cache_test_child', - 'tags' => array('render_cache_tag_child:1', 'render_cache_tag_child:2'), - ), - '#markup' => '', - ), - ); - - // Render the element and confirm that it goes through the rendering - // process (which will set $element['#printed']). - $element = $test_element; - drupal_render($element); - $this->assertTrue(isset($element['#printed']), 'No cache hit'); - - // Render the element again and confirm that it is retrieved from the cache - // instead (so $element['#printed'] will not be set). - $element = $test_element; - drupal_render($element); - $this->assertFalse(isset($element['#printed']), 'Cache hit'); - - // Test that cache tags are correctly collected from the render element, - // including the ones from its subchild. - $expected_tags = array( - 'render_cache_tag', - 'render_cache_tag_child:1', - 'render_cache_tag_child:2', - 'rendered', - ); - $this->assertEqual($expected_tags, $element['#cache']['tags'], 'Cache tags were collected from the element and its subchild.'); - - // Restore the previous request method. - \Drupal::request()->setMethod($request_method); - } - - /** - * Tests post-render cache callbacks functionality. - */ - function testDrupalRenderPostRenderCache() { - $context = array('foo' => $this->randomContextValue()); - $test_element = array(); - $test_element['#markup'] = ''; - $test_element['#attached']['drupalSettings']['foo'] = 'bar'; - $test_element['#post_render_cache']['common_test_post_render_cache'] = array( - $context - ); - - // #cache disabled. - $element = $test_element; - $element['#markup'] = '
#cache disabled
'; - $output = drupal_render_root($element); - $this->assertIdentical($output, 'overridden
', 'Output is overridden.'); - $this->assertIdentical($element['#markup'], 'overridden
', '#markup is overridden.'); - $expected_js_settings = [ - 'foo' => 'bar', - 'common_test' => $context, - ]; - $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.'); - - // The cache system is turned off for POST requests. - $request_method = \Drupal::request()->getMethod(); - \Drupal::request()->setMethod('GET'); - - // GET request: #cache enabled, cache miss. - $element = $test_element; - $element['#cache'] = array('cid' => 'post_render_cache_test_GET'); - $element['#markup'] = '#cache enabled, GET
'; - $output = drupal_render_root($element); - $this->assertIdentical($output, 'overridden
', 'Output is overridden.'); - $this->assertTrue(isset($element['#printed']), 'No cache hit'); - $this->assertIdentical($element['#markup'], 'overridden
', '#markup is overridden.'); - $expected_js_settings = [ - 'foo' => 'bar', - 'common_test' => $context, - ]; - $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.'); - - // GET request: validate cached data. - $element = array('#cache' => array('cid' => 'post_render_cache_test_GET')); - $cached_element = \Drupal::cache('render')->get('post_render_cache_test_GET')->data; - $expected_element = array( - '#markup' => '#cache enabled, GET
', - '#attached' => $test_element['#attached'], - '#post_render_cache' => $test_element['#post_render_cache'], - '#cache' => array('tags' => array('rendered')), - ); - $this->assertIdentical($cached_element, $expected_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.'); - - // GET request: #cache enabled, cache hit. - $element['#cache'] = array('cid' => 'post_render_cache_test_GET'); - $element['#markup'] = '#cache enabled, GET
'; - $output = drupal_render_root($element); - $this->assertIdentical($output, 'overridden
', 'Output is overridden.'); - $this->assertFalse(isset($element['#printed']), 'Cache hit'); - $this->assertIdentical($element['#markup'], 'overridden
', '#markup is overridden.'); - $expected_js_settings = [ - 'foo' => 'bar', - 'common_test' => $context, - ]; - $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.'); - - // Verify behavior when handling a non-GET request, e.g. a POST request: - // also in that case, #post_render_cache callbacks must be called. - \Drupal::request()->setMethod('POST'); - - // POST request: #cache enabled, cache miss. - $element = $test_element; - $element['#cache'] = array('cid' => 'post_render_cache_test_POST'); - $element['#markup'] = '#cache enabled, POST
'; - $output = drupal_render_root($element); - $this->assertIdentical($output, 'overridden
', 'Output is overridden.'); - $this->assertTrue(isset($element['#printed']), 'No cache hit'); - $this->assertIdentical($element['#markup'], 'overridden
', '#markup is overridden.'); - $expected_js_settings = [ - 'foo' => 'bar', - 'common_test' => $context, - ]; - $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.'); - - // POST request: Ensure no data was cached. - $cached_element = \Drupal::cache('render')->get('post_render_cache_test_POST'); - $this->assertFalse($cached_element, 'No data is cached because this is a POST request.'); - - // Restore the previous request method. - \Drupal::request()->setMethod($request_method); - } - - /** * Tests post-render cache callbacks functionality in children elements. */ function testDrupalRenderChildrenPostRenderCache() { @@ -677,31 +492,6 @@ function testDrupalRenderChildElementRenderCachePlaceholder() { } /** - * Tests a #post_render_cache callback that adds another #post_render_cache - * callback. - * - * E.g. when rendering a node in a #post_render_cache callback, the rendering - * of that node needs a #post_render_cache callback of its own to be executed - * (to render the node links). - */ - function testRecursivePostRenderCache() { - $context = array('foo' => $this->randomContextValue()); - $element = []; - $element['#markup'] = ''; - $element['#post_render_cache']['common_test_post_render_cache_recursion'] = array( - $context - ); - - $output = drupal_render_root($element); - $this->assertEqual('overridden
', $output, 'The output has been modified by the indirect, recursive #post_render_cache callback.'); - $this->assertIdentical($element['#markup'], 'overridden
', '#markup is overridden by the indirect, recursive #post_render_cache callback.'); - $expected_js_settings = [ - 'common_test' => $context, - ]; - $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified by the indirect, recursive #post_render_cache callback.'); - } - - /** * #pre_render callback for testDrupalRenderBubbling(). */ public static function bubblingPreRender($elements) { @@ -853,106 +643,4 @@ public function testDrupalProcessAttached() { } } - /** - * Tests \Drupal\Core\Render\Renderer::renderPlain(). - */ - public function testRenderPlain() { - $renderer = \Drupal::service('renderer'); - - $complex_child_markup = 'Imagine this is a render array for an entity.
'; - $parent_markup = 'Rendered!
'; - - $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('overridden
', $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('overridden
', $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('overridden
' . $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.'); - } - } - } diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php index 7018b80..8ab8f2c 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php @@ -8,11 +8,16 @@ namespace Drupal\Tests\Core\Render; +use Drupal\Core\Cache\MemoryBackend; +use Drupal\Core\Cache\NullBackend; use Drupal\Core\Render\Element; use Drupal\Core\Render\Renderer; +use Drupal\Core\Render\RendererInterface; use Drupal\Core\Template\Attribute; use Drupal\Core\Url; +use Drupal\system\Tests\Cache\MemoryBackendUnitTest; use Drupal\Tests\UnitTestCase; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; /** @@ -27,17 +32,20 @@ class RendererTest extends UnitTestCase { * @var \Drupal\Core\Render\Renderer */ protected $renderer; - protected $controllerResolver; - protected $themeManager; - protected $elementInfo; /** - * @var + * @var \Symfony\Component\HttpFoundation\RequestStack */ protected $requestStack; + /** + * @var \Drupal\Core\Cache\CacheFactoryInterface|\PHPUnit_Framework_MockObject_MockObject + */ protected $cacheFactory; + /** + * @var \Drupal\Core\Cache\CacheContexts|\PHPUnit_Framework_MockObject_MockObject + */ protected $cacheContexts; /** @@ -69,6 +77,11 @@ class RendererTest extends UnitTestCase { ]; /** + * @var \Drupal\Core\Cache\CacheBackendInterface + */ + protected $memoryCache; + + /** * {@inheritdoc} */ protected function setUp() { @@ -578,6 +591,439 @@ public function testRenderWithThemeArguments() { $this->assertEquals($this->renderer->render($element), $element['#foo'] . $element['#bar'], 'Passing arguments to theme functions works'); } + public function testRenderCache() { + // The cache system is turned off for POST requests. + $request = Request::create('/', 'GET'); + $this->requestStack->push($request); + + // Create an empty element. + $test_element = [ + '#cache' => [ + 'cid' => 'render_cache_test', + 'tags' => ['render_cache_tag'], + ], + '#markup' => '', + 'child' => [ + '#cache' => [ + 'cid' => 'render_cache_test_child', + 'tags' => ['render_cache_tag_child:1', 'render_cache_tag_child:2'], + ], + '#markup' => '', + ], + ]; + + // Setup a cache which acts like a cache, a memory backend. Adding a mock + // for such a generic test, couples the test too much to the internal + // implementation. + $cache = new MemoryBackend('render'); + $this->cacheFactory->expects($this->any()) + ->method('get') + ->with('render') + ->willReturn($cache); + + // Render the element and confirm that it goes through the rendering + // process (which will set $element['#printed']). + $element = $test_element; + $this->renderer->render($element); + $this->assertTrue(isset($element['#printed']), 'No cache hit'); + + // Render the element again and confirm that it is retrieved from the cache + // instead (so $element['#printed'] will not be set). + $element = $test_element; + $this->renderer->render($element); + $this->assertFalse(isset($element['#printed']), 'Cache hit'); + + // Test that cache tags are correctly collected from the render element, + // including the ones from its subchild. + $expected_tags = [ + 'render_cache_tag', + 'render_cache_tag_child:1', + 'render_cache_tag_child:2', + 'rendered', + ]; + $this->assertEquals($expected_tags, $element['#cache']['tags'], 'Cache tags were collected from the element and its subchild.'); + } + + /** + * Tests a #post_render_cache callback that adds another #post_render_cache + * callback. + * + * E.g. when rendering a node in a #post_render_cache callback, the rendering + * of that node needs a #post_render_cache callback of its own to be executed + * (to render the node links). + */ + public function testRenderRecursivePostRenderCache() { + $context = ['foo' => $this->randomContextValue()]; + $element = []; + $element['#markup'] = ''; + + $this->setupPostRenderCallbacks(); + + $element['#post_render_cache']['Drupal\Tests\Core\Render\PostRenderCacheRecursion::callback'] = [ + $context + ]; + + $output = $this->renderer->renderRoot($element); + $this->assertEquals('overridden
', $output, 'The output has been modified by the indirect, recursive #post_render_cache callback.'); + $this->assertSame($element['#markup'], 'overridden
', '#markup is overridden by the indirect, recursive #post_render_cache callback.'); + $expected_js_settings = [ + 'common_test' => $context, + ]; + $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified by the indirect, recursive #post_render_cache callback.'); + } + + /** + * Generates a random context value for the post-render cache tests. + * + * The #context array used by the post-render cache callback will generally + * be used to provide metadata like entity IDs, field machine names, paths, + * etc. for JavaScript replacement of content or assets. In this test, the + * callbacks common_test_post_render_cache() and + * common_test_post_render_cache_placeholder() render the context inside test + * HTML, so using any random string would sometimes cause random test + * failures because the test output would be unparseable. Instead, we provide + * random tokens for replacement. + * + * @see common_test_post_render_cache() + * @see common_test_post_render_cache_placeholder() + * @see https://drupal.org/node/2151609 + */ + protected function randomContextValue() { + $tokens = ['llama', 'alpaca', 'camel', 'moose', 'elk']; + return $tokens[mt_rand(0, 4)]; + } + + public function testDrupalRenderChildrenAttached() { + // The cache system is turned off for POST requests. + $request = Request::create('/', 'GET'); + $this->requestStack->push($request); + + $cache = new MemoryBackend('render'); + $this->cacheFactory->expects($this->any()) + ->method('get') + ->willReturn($cache); + + // Mock the cache contexts. + $this->cacheContexts->expects($this->any()) + ->method('convertTokensToKeys') + ->willReturnArgument(0); + + $this->elementInfo->expects($this->any()) + ->method('getInfo') + ->willReturn([]); + + + // Create an element with a child and subchild. Each element loads a + // different library using #attached. + $element = [ + '#type' => 'container', + '#cache' => [ + 'keys' => ['simpletest', 'drupal_render', 'children_attached'], + ], + '#attached' => ['library' => ['test/parent']], + '#title' => 'Parent', + ]; + $element['child'] = [ + '#type' => 'container', + '#attached' => ['library' => ['test/child']], + '#title' => 'Child', + ]; + $element['child']['subchild'] = [ + '#attached' => ['library' => ['test/subchild']], + '#markup' => 'Subchild', + ]; + + // Render the element and verify the presence of #attached JavaScript. + $this->renderer->render($element); + $expected_libraries = ['test/parent', 'test/child', 'test/subchild']; + $this->assertEquals($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.'); + + // Load the element from cache and verify the presence of the #attached + // JavaScript. + $element = ['#cache' => ['keys' => ['simpletest', 'drupal_render', 'children_attached']]]; + $this->assertTrue(strlen($this->renderer->render($element)) > 0, 'The element was retrieved from cache.'); + $this->assertEquals($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.'); + } + + protected function drupalRenderPostCacheCacheElement() { + $context = ['foo' => $this->randomContextValue()]; + $test_element = []; + $test_element['#markup'] = ''; + $test_element['#attached']['drupalSettings']['foo'] = 'bar'; + $test_element['#post_render_cache']['Drupal\Tests\Core\Render\PostRenderCache::callback'] = [ + $context + ]; + + return [$test_element, $context]; + } + + public function testPostRenderCacheWithCacheDisabled() { + list ($element, $context) = $this->drupalRenderPostCacheCacheElement(); + $this->setupPostRenderCallbacks(); + $this->setupNullCache(); + + // #cache disabled. + $element['#markup'] = '#cache disabled
'; + $output = $this->renderer->renderRoot($element); + $this->assertSame($output, 'overridden
', 'Output is overridden.'); + $this->assertSame($element['#markup'], 'overridden
', '#markup is overridden.'); + $expected_js_settings = [ + 'foo' => 'bar', + 'common_test' => $context, + ]; + $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.'); + } + + + public function testPostRenderCacheWithCacheMiss() { + list ($test_element, $context) = $this->drupalRenderPostCacheCacheElement(); + $element = $test_element; + $this->setupPostRenderCallbacks(); + $this->setupMemoryCache(); + + // The cache system is turned off for POST requests. + $request = Request::create('/'); + $this->requestStack->push($request); + + // GET request: #cache enabled, cache miss. + $element['#cache'] = ['cid' => 'post_render_cache_test_GET']; + $element['#markup'] = '#cache enabled, GET
'; + $output = $this->renderer->renderRoot($element); + $this->assertSame($output, 'overridden
', 'Output is overridden.'); + $this->assertTrue(isset($element['#printed']), 'No cache hit'); + $this->assertSame($element['#markup'], 'overridden
', '#markup is overridden.'); + $expected_js_settings = [ + 'foo' => 'bar', + 'common_test' => $context, + ]; + $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.'); + + // GET request: validate cached data. + $cached_element = $this->memoryCache->get('post_render_cache_test_GET')->data; + $expected_element = [ + '#markup' => '#cache enabled, GET
', + '#attached' => $test_element['#attached'], + '#post_render_cache' => $test_element['#post_render_cache'], + '#cache' => ['tags' => ['rendered']], + ]; + $this->assertSame($cached_element, $expected_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.'); + + // GET request: #cache enabled, cache hit. + $element = $test_element; + $element['#cache'] = ['cid' => 'post_render_cache_test_GET']; + $element['#markup'] = '#cache enabled, GET
'; + $output = $this->renderer->renderRoot($element); + $this->assertSame($output, 'overridden
', 'Output is overridden.'); + $this->assertFalse(isset($element['#printed']), 'Cache hit'); + $this->assertSame($element['#markup'], 'overridden
', '#markup is overridden.'); + $expected_js_settings = [ + 'foo' => 'bar', + 'common_test' => $context, + ]; + $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.'); + } + + public function testPostRenderCacheWithPostRequest() { + list ($test_element, $context) = $this->drupalRenderPostCacheCacheElement(); + $this->setupPostRenderCallbacks(); + $this->setupMemoryCache(); + + // Verify behavior when handling a non-GET request, e.g. a POST request: + // also in that case, #post_render_cache callbacks must be called. + $request = Request::create('/', 'POST'); + $this->requestStack->push($request); + + // POST request: #cache enabled, cache miss. + $element = $test_element; + $element['#cache'] = ['cid' => 'post_render_cache_test_POST']; + $element['#markup'] = '#cache enabled, POST
'; + $output = $this->renderer->renderRoot($element); + $this->assertSame($output, 'overridden
', 'Output is overridden.'); + $this->assertTrue(isset($element['#printed']), 'No cache hit'); + $this->assertSame($element['#markup'], 'overridden
', '#markup is overridden.'); + $expected_js_settings = [ + 'foo' => 'bar', + 'common_test' => $context, + ]; + $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.'); + + // POST request: Ensure no data was cached. + $cached_element = $this->memoryCache->get('post_render_cache_test_POST'); + $this->assertFalse($cached_element, 'No data is cached because this is a POST request.'); + } + + protected function setupPostRenderCallbacks() { + $post_render_callback_recursive = new PostRenderCacheRecursion($this->renderer); + $post_render_callback = new PostRenderCache($this->renderer); + + $this->controllerResolver->expects($this->any()) + ->method('getControllerFromDefinition') + ->willReturnMap([ + [ + 'Drupal\Tests\Core\Render\PostRenderCacheRecursion::callback', + [$post_render_callback_recursive, 'callback'] + ], + [ + 'Drupal\Tests\Core\Render\PostRenderCache::callback', + [$post_render_callback, 'callback'] + ], + ]); + } + + protected function setupNullCache() { + $this->cacheFactory->expects($this->any()) + ->method('get') + ->willReturn(new NullBackend('render')); + } + + protected function setupMemoryCache() { + $this->memoryCache = $this->memoryCache ?: new MemoryBackend('render'); + + $this->cacheFactory->expects($this->any()) + ->method('get') + ->willReturn($this->memoryCache); + } + + protected function setupRenderPlainComplexElements() { + $complex_child_markup = 'Imagine this is a render array for an entity.
'; + $parent_markup = 'Rendered!
'; + + $complex_child_template = [ + '#markup' => $complex_child_markup, + '#attached' => [ + 'library' => [ + 'core/drupal', + ], + ], + '#cache' => [ + 'tags' => [ + 'test:complex_child', + ], + ], + '#post_render_cache' => [ + 'Drupal\Tests\Core\Render\PostRenderCache::callback' => [ + ['foo' => $this->getRandomGenerator()->string()], + ], + ], + ]; + + return [$complex_child_markup, $parent_markup, $complex_child_template]; + } + + /** + * @expectedException \LogicException + */ + public function testRenderPlainWithNestedRenderRoot() { + list($complex_child_markup, $parent_markup, $complex_child_template) = $this->setupRenderPlainComplexElements(); + $renderer = $this->renderer; + $this->setupGetRequest(); + + // Case 1: ::renderRoot() with nested ::renderRoot(). + // 'Renderer::renderRoot() may not be called inside of another Renderer::renderRoot() call, this must trigger an exception.'); + $complex_child = $complex_child_template; + $callable = function () use ($renderer, $complex_child) { + $renderer->renderRoot($complex_child); + }; + $this->controllerResolver->expects($this->any()) + ->method('getControllerFromDefinition') + ->willReturnCallback(function($controller) use ($renderer, $callable) { + if ($controller == 'Drupal\Tests\Core\Render\PostRenderCache::callback') { + return [new PostRenderCache($renderer), 'callback']; + } + return $callable; + }); + + $page = [ + 'content' => [ + '#pre_render' => [ + $callable + ], + '#suffix' => $parent_markup, + ] + ]; + $renderer->renderRoot($page); + } + + public function testRenderPlainWithNestedRender() { + list($complex_child_markup, $parent_markup, $complex_child_template) = $this->setupRenderPlainComplexElements(); + $renderer = $this->renderer; + $this->setupGetRequest(); + + // Case 2: ::renderRoot() with nested ::render(). + // Renderer::render() may be called from anywhere, including from inside of another Renderer::renderRoot() call. Bubbling must be performed.'); + $complex_child = $complex_child_template; + + $callable = function ($elements) use ($renderer, $complex_child, $complex_child_markup, $parent_markup) { + $elements['#markup'] = $renderer->render($complex_child); + $this->assertEquals($complex_child_markup, $elements['#markup'], 'Rendered complex child output as expected, without the #post_render_cache callback executed.'); + return $elements; + }; + $this->controllerResolver->expects($this->any()) + ->method('getControllerFromDefinition') + ->willReturnCallback(function($controller) use ($renderer, $callable) { + if ($controller == 'Drupal\Tests\Core\Render\PostRenderCache::callback') { + return [new PostRenderCache($renderer), 'callback']; + } + return $callable; + }); + + $page = [ + 'content' => [ + '#pre_render' => [ + $callable + ], + '#suffix' => $parent_markup, + ] + ]; + $output = $renderer->renderRoot($page); + + $this->assertEquals('overridden
', $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.'); + } + + public function testRenderPlainWithNestedRenderPlain() { + list($complex_child_markup, $parent_markup, $complex_child_template) = $this->setupRenderPlainComplexElements(); + $renderer = $this->renderer; + $this->setupGetRequest(); + + // Case 3: ::renderRoot() with nested ::renderPlain(). + // 'Renderer::renderPlain() may be called from anywhere, including from inside of another Renderer::renderRoot() call.'); + $complex_child = $complex_child_template; + + $callable = function ($elements) use ($renderer, $complex_child, $parent_markup) { + $elements['#markup'] = $renderer->renderPlain($complex_child); + $this->assertEquals('overridden
', $elements['#markup'], 'Rendered complex child output as expected, with the #post_render_cache callback executed.'); + return $elements; + }; + $this->controllerResolver->expects($this->any()) + ->method('getControllerFromDefinition') + ->willReturnCallback(function($controller) use ($renderer, $callable) { + if ($controller == 'Drupal\Tests\Core\Render\PostRenderCache::callback') { + return [new PostRenderCache($renderer), 'callback']; + } + return $callable; + }); + + $page = [ + 'content' => [ + '#pre_render' => [ + $callable + ], + '#suffix' => $parent_markup, + ] + ]; + $output = $renderer->renderRoot($page); + $this->assertEquals('overridden
' . $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.'); + } + + protected function setupGetRequest() { + $request = Request::create('/'); + $this->requestStack->push($request); + } + } class TestAccessClass { @@ -599,4 +1045,87 @@ public function preRenderPrinted($elements) { return $elements; } +} + +class PostRenderCacheRecursion { + + /** + * @var \Drupal\Core\Render\RendererInterface + */ + protected $renderer; + + public function __construct(RendererInterface $renderer) { + $this->renderer = $renderer; + } + + /** + * #post_render_cache callback; bubbles another #post_render_cache callback. + * + * @param array $element + * A render array with the following keys: + * - #markup + * - #attached + * @param array $context + * An array with the following keys: + * - foo: contains a random string. + * + * @return array $element + * The updated $element. + */ + public function callback(array $element, array $context) { + // Render a child which itself also has a #post_render_cache callback that + // must be bubbled. + $child = []; + $child['#markup'] = 'foo'; + $child['#post_render_cache']['Drupal\Tests\Core\Render\PostRenderCache::callback'][] = $context; + + // Render the child. + $element['#markup'] = $this->renderer->render($child); + + return $element; + } + +} + +class PostRenderCache { + + /** + * @var \Drupal\Core\Render\RendererInterface + */ + protected $renderer; + + public function __construct(RendererInterface $renderer) { + $this->renderer = $renderer; + } + + /** + * #post_render_cache callback; modifies #markup, #attached and #context_test. + * + * @param array $element + * A render array with the following keys: + * - #markup + * - #attached + * @param array $context + * An array with the following keys: + * - foo: contains a random string. + * + * @return array $element + * The updated $element. + */ + public function callback(array $element, array $context) { + // Override #markup. + $element['#markup'] = 'overridden
'; + + // Extend #attached. + if (!isset($element['#attached']['drupalSettings']['common_test'])) { + $element['#attached']['drupalSettings']['common_test'] = []; + } + $element['#attached']['drupalSettings']['common_test'] += $context; + + // Set new property. + $element['#context_test'] = $context; + + return $element; + } + } \ No newline at end of file