Version 1
[yaffs-website] / web / core / tests / Drupal / Tests / Core / Render / RendererPlaceholdersTest.php
1 <?php
2
3 /**
4  * @file
5  * Contains \Drupal\Tests\Core\Render\RendererPlaceholdersTest.
6  */
7
8 namespace Drupal\Tests\Core\Render;
9
10 use Drupal\Component\Utility\Crypt;
11 use Drupal\Component\Utility\Html;
12 use Drupal\Core\Cache\Cache;
13 use Drupal\Core\Render\Markup;
14 use Drupal\Core\Render\RenderContext;
15
16 /**
17  * @coversDefaultClass \Drupal\Core\Render\Renderer
18  * @covers \Drupal\Core\Render\RenderCache
19  * @covers \Drupal\Core\Render\PlaceholderingRenderCache
20  * @group Render
21  */
22 class RendererPlaceholdersTest extends RendererTestBase {
23
24   /**
25    * {@inheritdoc}
26    */
27   protected function setUp() {
28     // Disable the required cache contexts, so that this test can test just the
29     // placeholder replacement behavior.
30     $this->rendererConfig['required_cache_contexts'] = [];
31
32     parent::setUp();
33   }
34
35   /**
36    * Provides the two classes of placeholders: cacheable and uncacheable.
37    *
38    * i.e. with or without #cache[keys].
39    *
40    * Also, different types:
41    * - A) automatically generated placeholder
42    *   - 1) manually triggered (#create_placeholder = TRUE)
43    *   - 2) automatically triggered (based on max-age = 0 at the top level)
44    *   - 3) automatically triggered (based on high cardinality cache contexts at
45    *        the top level)
46    *   - 4) automatically triggered (based on high-invalidation frequency cache
47    *        tags at the top level)
48    *   - 5) automatically triggered (based on max-age = 0 in its subtree, i.e.
49    *        via bubbling)
50    *   - 6) automatically triggered (based on high cardinality cache contexts in
51    *        its subtree, i.e. via bubbling)
52    *   - 7) automatically triggered (based on high-invalidation frequency cache
53    *        tags in its subtree, i.e. via bubbling)
54    * - B) manually generated placeholder
55    *
56    * So, in total 2*8 = 16 permutations. (On one axis: uncacheable vs.
57    * uncacheable = 2; on the other axis: A1–7 and B = 8.)
58    *
59    * @todo Case A5 is not yet supported by core. So that makes for only 14
60    *   permutations currently, instead of 16. That will be done in
61    *   https://www.drupal.org/node/2559847
62    *
63    * @return array
64    */
65   public function providerPlaceholders() {
66     $args = [$this->randomContextValue()];
67
68     $generate_placeholder_markup = function($cache_keys = NULL) use ($args) {
69       $token_render_array = [
70         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
71       ];
72       if (is_array($cache_keys)) {
73         $token_render_array['#cache']['keys'] = $cache_keys;
74       }
75       $token = Crypt::hashBase64(serialize($token_render_array));
76       // \Drupal\Core\Render\Markup::create() is necessary as the render
77       // system would mangle this markup. As this is exactly what happens at
78       // runtime this is a valid use-case.
79       return Markup::create('<drupal-render-placeholder callback="Drupal\Tests\Core\Render\PlaceholdersTest::callback" arguments="' . '0=' . $args[0] . '" token="' . $token . '"></drupal-render-placeholder>');
80     };
81
82     $extract_placeholder_render_array = function ($placeholder_render_array) {
83       return array_intersect_key($placeholder_render_array, ['#lazy_builder' => TRUE, '#cache' => TRUE]);
84     };
85
86     // Note the presence of '#create_placeholder'.
87     $base_element_a1 = [
88       '#attached' => [
89         'drupalSettings' => [
90           'foo' => 'bar',
91         ],
92       ],
93       'placeholder' => [
94         '#cache' => [
95           'contexts' => [],
96         ],
97         '#create_placeholder' => TRUE,
98         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
99       ],
100     ];
101     // Note the absence of '#create_placeholder', presence of max-age=0 at the
102     // top level.
103     $base_element_a2 = [
104       '#attached' => [
105         'drupalSettings' => [
106           'foo' => 'bar',
107         ],
108       ],
109       'placeholder' => [
110         '#cache' => [
111           'contexts' => [],
112           'max-age' => 0,
113         ],
114         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
115       ],
116     ];
117     // Note the absence of '#create_placeholder', presence of high cardinality
118     // cache context at the top level.
119     $base_element_a3 = [
120       '#attached' => [
121         'drupalSettings' => [
122           'foo' => 'bar',
123         ],
124       ],
125       'placeholder' => [
126         '#cache' => [
127           'contexts' => ['user'],
128         ],
129         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
130       ],
131     ];
132     // Note the absence of '#create_placeholder', presence of high-invalidation
133     // frequency cache tag at the top level.
134     $base_element_a4 = [
135       '#attached' => [
136         'drupalSettings' => [
137           'foo' => 'bar',
138         ],
139       ],
140       'placeholder' => [
141         '#cache' => [
142           'contexts' => [],
143           'tags' => ['current-temperature'],
144         ],
145         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
146       ],
147     ];
148     // Note the absence of '#create_placeholder', presence of max-age=0 created
149     // by the #lazy_builder callback.
150     // @todo in https://www.drupal.org/node/2559847
151     $base_element_a5 = [];
152     // Note the absence of '#create_placeholder', presence of high cardinality
153     // cache context created by the #lazy_builder callback.
154     // @see \Drupal\Tests\Core\Render\PlaceholdersTest::callbackPerUser()
155     $base_element_a6 = [
156       '#attached' => [
157         'drupalSettings' => [
158           'foo' => 'bar',
159         ],
160       ],
161       'placeholder' => [
162         '#cache' => [
163           'contexts' => [],
164         ],
165         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callbackPerUser', $args],
166       ],
167     ];
168     // Note the absence of '#create_placeholder', presence of high-invalidation
169     // frequency cache tag created by the #lazy_builder callback.
170     // @see \Drupal\Tests\Core\Render\PlaceholdersTest::callbackTagCurrentTemperature()
171     $base_element_a7 = [
172       '#attached' => [
173         'drupalSettings' => [
174           'foo' => 'bar',
175         ],
176       ],
177       'placeholder' => [
178         '#cache' => [
179           'contexts' => [],
180         ],
181         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callbackTagCurrentTemperature', $args],
182       ],
183     ];
184     // Note the absence of '#create_placeholder', but the presence of
185     // '#attached[placeholders]'.
186     $base_element_b = [
187       '#markup' => $generate_placeholder_markup(),
188       '#attached' => [
189         'drupalSettings' => [
190           'foo' => 'bar',
191         ],
192         'placeholders' => [
193           (string) $generate_placeholder_markup() => [
194             '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
195           ],
196         ],
197       ],
198     ];
199
200     $keys = ['placeholder', 'output', 'can', 'be', 'render', 'cached', 'too'];
201
202     $cases = [];
203
204     // Case one: render array that has a placeholder that is:
205     // - automatically created, but manually triggered (#create_placeholder = TRUE)
206     // - uncacheable
207     $element_without_cache_keys = $base_element_a1;
208     $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a1['placeholder']);
209     $cases[] = [
210       $element_without_cache_keys,
211       $args,
212       $expected_placeholder_render_array,
213       FALSE,
214       [],
215       [],
216       [],
217     ];
218
219     // Case two: render array that has a placeholder that is:
220     // - automatically created, but manually triggered (#create_placeholder = TRUE)
221     // - cacheable
222     $element_with_cache_keys = $base_element_a1;
223     $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
224     $expected_placeholder_render_array['#cache']['keys'] = $keys;
225     $cases[] = [
226       $element_with_cache_keys,
227       $args,
228       $expected_placeholder_render_array,
229       $keys,
230       [],
231       [],
232       [
233         '#markup' => '<p>This is a rendered placeholder!</p>',
234         '#attached' => [
235           'drupalSettings' => [
236             'dynamic_animal' => $args[0],
237           ],
238         ],
239         '#cache' => [
240           'contexts' => [],
241           'tags' => [],
242           'max-age' => Cache::PERMANENT,
243         ],
244       ],
245     ];
246
247     // Case three: render array that has a placeholder that is:
248     // - automatically created, and automatically triggered due to max-age=0
249     // - uncacheable
250     $element_without_cache_keys = $base_element_a2;
251     $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a2['placeholder']);
252     $cases[] = [
253       $element_without_cache_keys,
254       $args,
255       $expected_placeholder_render_array,
256       FALSE,
257       [],
258       [],
259       [],
260     ];
261
262     // Case four: render array that has a placeholder that is:
263     // - automatically created, but automatically triggered due to max-age=0
264     // - cacheable
265     $element_with_cache_keys = $base_element_a2;
266     $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
267     $expected_placeholder_render_array['#cache']['keys'] = $keys;
268     $cases[] = [
269       $element_with_cache_keys,
270       $args,
271       $expected_placeholder_render_array,
272       FALSE,
273       [],
274       [],
275       [],
276     ];
277
278     // Case five: render array that has a placeholder that is:
279     // - automatically created, and automatically triggered due to high
280     //   cardinality cache contexts
281     // - uncacheable
282     $element_without_cache_keys = $base_element_a3;
283     $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a3['placeholder']);
284     $cases[] = [
285       $element_without_cache_keys,
286       $args,
287       $expected_placeholder_render_array,
288       FALSE,
289       [],
290       [],
291       [],
292     ];
293
294     // Case six: render array that has a placeholder that is:
295     // - automatically created, and automatically triggered due to high
296     //   cardinality cache contexts
297     // - cacheable
298     $element_with_cache_keys = $base_element_a3;
299     $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
300     $expected_placeholder_render_array['#cache']['keys'] = $keys;
301     // The CID parts here consist of the cache keys plus the 'user' cache
302     // context, which in this unit test is simply the given cache context token,
303     // see \Drupal\Tests\Core\Render\RendererTestBase::setUp().
304     $cid_parts = array_merge($keys, ['user']);
305     $cases[] = [
306       $element_with_cache_keys,
307       $args,
308       $expected_placeholder_render_array,
309       $cid_parts,
310       [],
311       [],
312       [
313         '#markup' => '<p>This is a rendered placeholder!</p>',
314         '#attached' => [
315           'drupalSettings' => [
316             'dynamic_animal' => $args[0],
317           ],
318         ],
319         '#cache' => [
320           'contexts' => ['user'],
321           'tags' => [],
322           'max-age' => Cache::PERMANENT,
323         ],
324       ],
325     ];
326
327     // Case seven: render array that has a placeholder that is:
328     // - automatically created, and automatically triggered due to high
329     //   invalidation frequency cache tags
330     // - uncacheable
331     $element_without_cache_keys = $base_element_a4;
332     $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a4['placeholder']);
333     $cases[] = [
334       $element_without_cache_keys,
335       $args,
336       $expected_placeholder_render_array,
337       FALSE,
338       [],
339       [],
340       [],
341     ];
342
343     // Case eight: render array that has a placeholder that is:
344     // - automatically created, and automatically triggered due to high
345     //   invalidation frequency cache tags
346     // - cacheable
347     $element_with_cache_keys = $base_element_a4;
348     $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
349     $expected_placeholder_render_array['#cache']['keys'] = $keys;
350     $cases[] = [
351       $element_with_cache_keys,
352       $args,
353       $expected_placeholder_render_array,
354       $keys,
355       [],
356       [],
357       [
358         '#markup' => '<p>This is a rendered placeholder!</p>',
359         '#attached' => [
360           'drupalSettings' => [
361             'dynamic_animal' => $args[0],
362           ],
363         ],
364         '#cache' => [
365           'contexts' => [],
366           'tags' => ['current-temperature'],
367           'max-age' => Cache::PERMANENT,
368         ],
369       ],
370     ];
371
372     // Case nine: render array that DOES NOT have a placeholder that is:
373     // - NOT created, despite max-age=0 that is bubbled
374     // - uncacheable
375     // (because the render element with #lazy_builder does not have #cache[keys]
376     // and hence the max-age=0 bubbles up further)
377     // @todo in https://www.drupal.org/node/2559847
378
379     // Case ten: render array that has a placeholder that is:
380     // - automatically created, and automatically triggered due to max-age=0
381     //   that is bubbled
382     // - cacheable
383     // @todo in https://www.drupal.org/node/2559847
384
385     // Case eleven: render array that DOES NOT have a placeholder that is:
386     // - NOT created, despite high cardinality cache contexts that are bubbled
387     // - uncacheable
388     $element_without_cache_keys = $base_element_a6;
389     $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a6['placeholder']);
390     $cases[] = [
391       $element_without_cache_keys,
392       $args,
393       $expected_placeholder_render_array,
394       FALSE,
395       ['user'],
396       [],
397       [],
398     ];
399
400     // Case twelve: render array that has a placeholder that is:
401     // - automatically created, and automatically triggered due to high
402     //   cardinality cache contexts that are bubbled
403     // - cacheable
404     $element_with_cache_keys = $base_element_a6;
405     $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
406     $expected_placeholder_render_array['#cache']['keys'] = $keys;
407     $cases[] = [
408       $element_with_cache_keys,
409       $args,
410       $expected_placeholder_render_array,
411       $keys,
412       ['user'],
413       [],
414       [
415         '#markup' => '<p>This is a rendered placeholder!</p>',
416         '#attached' => [
417           'drupalSettings' => [
418             'dynamic_animal' => $args[0],
419           ],
420         ],
421         '#cache' => [
422           'contexts' => ['user'],
423           'tags' => [],
424           'max-age' => Cache::PERMANENT,
425         ],
426       ],
427     ];
428
429     // Case thirteen: render array that has a placeholder that is:
430     // - automatically created, and automatically triggered due to high
431     //   invalidation frequency cache tags that are bubbled
432     // - uncacheable
433     $element_without_cache_keys = $base_element_a7;
434     $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a7['placeholder']);
435     $cases[] = [
436       $element_without_cache_keys,
437       $args,
438       $expected_placeholder_render_array,
439       FALSE,
440       [],
441       ['current-temperature'],
442       [],
443     ];
444
445     // Case fourteen: render array that has a placeholder that is:
446     // - automatically created, and automatically triggered due to high
447     //   invalidation frequency cache tags that are bubbled
448     // - cacheable
449     $element_with_cache_keys = $base_element_a7;
450     $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
451     $expected_placeholder_render_array['#cache']['keys'] = $keys;
452     $cases[] = [
453       $element_with_cache_keys,
454       $args,
455       $expected_placeholder_render_array,
456       $keys,
457       [],
458       [],
459       [
460         '#markup' => '<p>This is a rendered placeholder!</p>',
461         '#attached' => [
462           'drupalSettings' => [
463             'dynamic_animal' => $args[0],
464           ],
465         ],
466         '#cache' => [
467           'contexts' => [],
468           'tags' => ['current-temperature'],
469           'max-age' => Cache::PERMANENT,
470         ],
471       ],
472     ];
473
474     // Case fifteen: render array that has a placeholder that is:
475     // - manually created
476     // - uncacheable
477     $x = $base_element_b;
478     $expected_placeholder_render_array = $x['#attached']['placeholders'][(string) $generate_placeholder_markup()];
479     unset($x['#attached']['placeholders'][(string) $generate_placeholder_markup()]['#cache']);
480     $cases[] = [
481       $x,
482       $args,
483       $expected_placeholder_render_array,
484       FALSE,
485       [],
486       [],
487       [],
488     ];
489
490     // Case sixteen: render array that has a placeholder that is:
491     // - manually created
492     // - cacheable
493     $x = $base_element_b;
494     $x['#markup'] = $placeholder_markup = $generate_placeholder_markup($keys);
495     $placeholder_markup = (string) $placeholder_markup;
496     $x['#attached']['placeholders'] = [
497       $placeholder_markup => [
498         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
499         '#cache' => ['keys' => $keys],
500       ],
501     ];
502     $expected_placeholder_render_array = $x['#attached']['placeholders'][$placeholder_markup];
503     $cases[] = [
504       $x,
505       $args,
506       $expected_placeholder_render_array,
507       $keys,
508       [],
509       [],
510       [
511         '#markup' => '<p>This is a rendered placeholder!</p>',
512         '#attached' => [
513           'drupalSettings' => [
514             'dynamic_animal' => $args[0],
515           ],
516         ],
517         '#cache' => [
518           'contexts' => [],
519           'tags' => [],
520           'max-age' => Cache::PERMANENT,
521         ],
522       ],
523     ];
524
525     return $cases;
526   }
527
528   /**
529    * Generates an element with a placeholder.
530    *
531    * @return array
532    *   An array containing:
533    *   - A render array containing a placeholder.
534    *   - The context used for that #lazy_builder callback.
535    */
536   protected function generatePlaceholderElement() {
537     $args = [$this->randomContextValue()];
538     $test_element = [];
539     $test_element['#attached']['drupalSettings']['foo'] = 'bar';
540     $test_element['placeholder']['#cache']['keys'] = ['placeholder', 'output', 'can', 'be', 'render', 'cached', 'too'];
541     $test_element['placeholder']['#cache']['contexts'] = [];
542     $test_element['placeholder']['#create_placeholder'] = TRUE;
543     $test_element['placeholder']['#lazy_builder'] = ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args];
544
545     return [$test_element, $args];
546   }
547
548   /**
549    * @param false|array $cid_parts
550    * @param array $expected_data
551    *   FALSE if no render cache item is expected, a render array with the
552    *   expected values if a render cache item is expected.
553    * @param string[] $bubbled_cache_contexts
554    *   Additional cache contexts that were bubbled when the placeholder was
555    *   rendered.
556    */
557   protected function assertPlaceholderRenderCache($cid_parts, array $bubbled_cache_contexts, array $expected_data) {
558     if ($cid_parts !== FALSE) {
559       if ($bubbled_cache_contexts) {
560         // Verify render cached placeholder.
561         $cached_element = $this->memoryCache->get(implode(':', $cid_parts))->data;
562         $expected_redirect_element = [
563           '#cache_redirect' => TRUE,
564           '#cache' => $expected_data['#cache'] + [
565             'keys' => $cid_parts,
566             'bin' => 'render',
567           ],
568         ];
569         $this->assertEquals($expected_redirect_element, $cached_element, 'The correct cache redirect exists.');
570       }
571
572       // Verify render cached placeholder.
573       $cached = $this->memoryCache->get(implode(':', array_merge($cid_parts, $bubbled_cache_contexts)));
574       $cached_element = $cached->data;
575       $this->assertEquals($expected_data, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by the placeholder being replaced.');
576     }
577   }
578   /**
579    * @covers ::render
580    * @covers ::doRender
581    *
582    * @dataProvider providerPlaceholders
583    */
584   public function testUncacheableParent($element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) {
585     if ($placeholder_cid_parts) {
586       $this->setupMemoryCache();
587     }
588     else {
589       $this->setUpUnusedCache();
590     }
591
592     $this->setUpRequest('GET');
593
594     // No #cache on parent element.
595     $element['#prefix'] = '<p>#cache disabled</p>';
596     $output = $this->renderer->renderRoot($element);
597     $this->assertSame('<p>#cache disabled</p><p>This is a rendered placeholder!</p>', (string) $output, 'Output is overridden.');
598     $this->assertSame('<p>#cache disabled</p><p>This is a rendered placeholder!</p>', (string) $element['#markup'], '#markup is overridden.');
599     $expected_js_settings = [
600       'foo' => 'bar',
601       'dynamic_animal' => $args[0],
602     ];
603     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
604     $this->assertPlaceholderRenderCache($placeholder_cid_parts, $bubbled_cache_contexts, $placeholder_expected_render_cache_array);
605   }
606
607   /**
608    * @covers ::render
609    * @covers ::doRender
610    * @covers \Drupal\Core\Render\RenderCache::get
611    * @covers \Drupal\Core\Render\RenderCache::set
612    * @covers \Drupal\Core\Render\RenderCache::createCacheID
613    *
614    * @dataProvider providerPlaceholders
615    */
616   public function testCacheableParent($test_element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) {
617     $element = $test_element;
618     $this->setupMemoryCache();
619
620     $this->setUpRequest('GET');
621
622     $token = Crypt::hashBase64(serialize($expected_placeholder_render_array));
623     $placeholder_callback = $expected_placeholder_render_array['#lazy_builder'][0];
624     $expected_placeholder_markup = '<drupal-render-placeholder callback="' . $placeholder_callback . '" arguments="0=' . $args[0] . '" token="' . $token . '"></drupal-render-placeholder>';
625     $this->assertSame($expected_placeholder_markup, Html::normalize($expected_placeholder_markup), 'Placeholder unaltered by Html::normalize() which is used by FilterHtmlCorrector.');
626
627     // GET request: #cache enabled, cache miss.
628     $element['#cache'] = ['keys' => ['placeholder_test_GET']];
629     $element['#prefix'] = '<p>#cache enabled, GET</p>';
630     $output = $this->renderer->renderRoot($element);
631     $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $output, 'Output is overridden.');
632     $this->assertTrue(isset($element['#printed']), 'No cache hit');
633     $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $element['#markup'], '#markup is overridden.');
634     $expected_js_settings = [
635       'foo' => 'bar',
636       'dynamic_animal' => $args[0],
637     ];
638     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
639     $this->assertPlaceholderRenderCache($placeholder_cid_parts, $bubbled_cache_contexts, $placeholder_expected_render_cache_array);
640
641     // GET request: validate cached data.
642     $cached = $this->memoryCache->get('placeholder_test_GET');
643     // There are three edge cases, where the shape of the render cache item for
644     // the parent (with CID 'placeholder_test_GET') is vastly different. These
645     // are the cases where:
646     // - the placeholder is uncacheable (because it has no #cache[keys]), and;
647     // - cacheability metadata that meets auto_placeholder_conditions is bubbled
648     $has_uncacheable_lazy_builder = !isset($test_element['placeholder']['#cache']['keys']) && isset($test_element['placeholder']['#lazy_builder']);
649     // Edge cases: always where both bubbling of an auto-placeholdering
650     // condition happens from within a #lazy_builder that is uncacheable.
651     // - uncacheable + A5 (cache max-age)
652     // @todo in https://www.drupal.org/node/2559847
653     // - uncacheable + A6 (cache context)
654     $edge_case_a6_uncacheable = $has_uncacheable_lazy_builder && $test_element['placeholder']['#lazy_builder'][0] === 'Drupal\Tests\Core\Render\PlaceholdersTest::callbackPerUser';
655     // - uncacheable + A7 (cache tag)
656     $edge_case_a7_uncacheable = $has_uncacheable_lazy_builder && $test_element['placeholder']['#lazy_builder'][0] === 'Drupal\Tests\Core\Render\PlaceholdersTest::callbackTagCurrentTemperature';
657     // The redirect-cacheable edge case: a high-cardinality cache context is
658     // bubbled from a #lazy_builder callback for an uncacheable placeholder. The
659     // element containing the uncacheable placeholder has cache keys set, and
660     // due to the bubbled cache contexts it creates a cache redirect.
661     if ($edge_case_a6_uncacheable) {
662       $cached_element = $cached->data;
663       $expected_redirect = [
664         '#cache_redirect' => TRUE,
665         '#cache' => [
666           'keys' => ['placeholder_test_GET'],
667           'contexts' => ['user'],
668           'tags' => [],
669           'max-age' => Cache::PERMANENT,
670           'bin' => 'render',
671         ],
672       ];
673       $this->assertEquals($expected_redirect, $cached_element);
674       // Follow the redirect.
675       $cached_element = $this->memoryCache->get('placeholder_test_GET:' . implode(':', $bubbled_cache_contexts))->data;
676       $expected_element = [
677         '#markup' => '<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>',
678         '#attached' => [
679           'drupalSettings' => [
680             'foo' => 'bar',
681             'dynamic_animal' => $args[0],
682           ],
683         ],
684         '#cache' => [
685           'contexts' => $bubbled_cache_contexts,
686           'tags' => [],
687           'max-age' => Cache::PERMANENT,
688         ],
689       ];
690       $this->assertEquals($expected_element, $cached_element, 'The parent is render cached with a redirect in ase a cache context is bubbled from an uncacheable child (no #cache[keys]) with a #lazy_builder.');
691     }
692     // The normally cacheable edge case: a high-invalidation frequency cache tag
693     // is bubbled from a #lazy_builder callback for an uncacheable placeholder.
694     // The element containing the uncacheable placeholder has cache keys set,
695     // and also has the bubbled cache tags.
696     elseif ($edge_case_a7_uncacheable) {
697       $cached_element = $cached->data;
698       $expected_element = [
699         '#markup' => '<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>',
700         '#attached' => [
701           'drupalSettings' => [
702             'foo' => 'bar',
703             'dynamic_animal' => $args[0],
704           ],
705         ],
706         '#cache' => [
707           'contexts' => [],
708           'tags' => $bubbled_cache_tags,
709           'max-age' => Cache::PERMANENT,
710         ],
711       ];
712       $this->assertEquals($expected_element, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by placeholder #lazy_builder callbacks.');
713     }
714     // The regular case.
715     else {
716       $cached_element = $cached->data;
717       $expected_element = [
718         '#markup' => '<p>#cache enabled, GET</p>' . $expected_placeholder_markup,
719         '#attached' => [
720           'drupalSettings' => [
721             'foo' => 'bar',
722           ],
723           'placeholders' => [
724             $expected_placeholder_markup => [
725               '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
726             ],
727           ],
728         ],
729         '#cache' => [
730           'contexts' => [],
731           'tags' => $bubbled_cache_tags,
732           'max-age' => Cache::PERMANENT,
733         ],
734       ];
735       $expected_element['#attached']['placeholders'][$expected_placeholder_markup] = $expected_placeholder_render_array;
736       $this->assertEquals($expected_element, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by placeholder #lazy_builder callbacks.');
737     }
738
739     // GET request: #cache enabled, cache hit.
740     $element = $test_element;
741     $element['#cache'] = ['keys' => ['placeholder_test_GET']];
742     $element['#prefix'] = '<p>#cache enabled, GET</p>';
743     $output = $this->renderer->renderRoot($element);
744     $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $output, 'Output is overridden.');
745     $this->assertFalse(isset($element['#printed']), 'Cache hit');
746     $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $element['#markup'], '#markup is overridden.');
747     $expected_js_settings = [
748       'foo' => 'bar',
749       'dynamic_animal' => $args[0],
750     ];
751     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
752   }
753
754   /**
755    * @covers ::render
756    * @covers ::doRender
757    * @covers \Drupal\Core\Render\RenderCache::get
758    * @covers ::replacePlaceholders
759    *
760    * @dataProvider providerPlaceholders
761    */
762   public function testCacheableParentWithPostRequest($test_element, $args) {
763     $this->setUpUnusedCache();
764
765     // Verify behavior when handling a non-GET request, e.g. a POST request:
766     // also in that case, placeholders must be replaced.
767     $this->setUpRequest('POST');
768
769     // POST request: #cache enabled, cache miss.
770     $element = $test_element;
771     $element['#cache'] = ['keys' => ['placeholder_test_POST']];
772     $element['#prefix'] = '<p>#cache enabled, POST</p>';
773     $output = $this->renderer->renderRoot($element);
774     $this->assertSame('<p>#cache enabled, POST</p><p>This is a rendered placeholder!</p>', (string) $output, 'Output is overridden.');
775     $this->assertTrue(isset($element['#printed']), 'No cache hit');
776     $this->assertSame('<p>#cache enabled, POST</p><p>This is a rendered placeholder!</p>', (string) $element['#markup'], '#markup is overridden.');
777     $expected_js_settings = [
778       'foo' => 'bar',
779       'dynamic_animal' => $args[0],
780     ];
781     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
782
783     // Even when the child element's placeholder is cacheable, it should not
784     // generate a render cache item.
785     $this->assertPlaceholderRenderCache(FALSE, [], []);
786   }
787
788   /**
789    * @covers ::render
790    * @covers ::doRender
791    * @covers \Drupal\Core\Render\RenderCache::get
792    * @covers \Drupal\Core\Render\PlaceholderingRenderCache::get
793    * @covers \Drupal\Core\Render\PlaceholderingRenderCache::set
794    * @covers ::replacePlaceholders
795    *
796    * @dataProvider providerPlaceholders
797    */
798   public function testPlaceholderingDisabledForPostRequests($test_element, $args) {
799     $this->setUpUnusedCache();
800     $this->setUpRequest('POST');
801
802     $element = $test_element;
803
804     // Render without replacing placeholders, to allow this test to see which
805     // #attached[placeholders] there are, if any.
806     $this->renderer->executeInRenderContext(new RenderContext(), function () use (&$element) {
807       return $this->renderer->render($element);
808     });
809     // Only test cases where the placeholders have been specified manually are
810     // allowed to have placeholders. This means that of the different situations
811     // listed in providerPlaceholders(), only type B can have attached
812     // placeholders. Everything else, whether:
813     // 1. manual placeholdering
814     // 2. automatic placeholdering via already-present cacheability metadata
815     // 3. automatic placeholdering via bubbled cacheability metadata
816     // All three of those should NOT result in placeholders.
817     if (!isset($test_element['#attached']['placeholders'])) {
818       $this->assertFalse(isset($element['#attached']['placeholders']), 'No placeholders created.');
819     }
820   }
821
822   /**
823    * Tests a placeholder that adds another placeholder.
824    *
825    * E.g. when rendering a node in a placeholder the rendering of that node
826    * needs a placeholder of its own to be executed (to render the node links).
827    *
828    * @covers ::render
829    * @covers ::doRender
830    * @covers ::replacePlaceholders
831    */
832   public function testRecursivePlaceholder() {
833     $args = [$this->randomContextValue()];
834     $element = [];
835     $element['#create_placeholder'] = TRUE;
836     $element['#lazy_builder'] = ['Drupal\Tests\Core\Render\RecursivePlaceholdersTest::callback', $args];
837
838     $output = $this->renderer->renderRoot($element);
839     $this->assertEquals('<p>This is a rendered placeholder!</p>', $output, 'The output has been modified by the indirect, recursive placeholder #lazy_builder callback.');
840     $this->assertSame((string) $element['#markup'], '<p>This is a rendered placeholder!</p>', '#markup is overridden by the indirect, recursive placeholder #lazy_builder callback.');
841     $expected_js_settings = [
842       'dynamic_animal' => $args[0],
843     ];
844     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified by the indirect, recursive placeholder #lazy_builder callback.');
845   }
846
847   /**
848    * @covers ::render
849    * @covers ::doRender
850    */
851   public function testInvalidLazyBuilder() {
852     $element = [];
853     $element['#lazy_builder'] = '\Drupal\Tests\Core\Render\PlaceholdersTest::callback';
854
855     $this->setExpectedException(\DomainException::class, 'The #lazy_builder property must have an array as a value.');
856     $this->renderer->renderRoot($element);
857   }
858
859   /**
860    * @covers ::render
861    * @covers ::doRender
862    */
863   public function testInvalidLazyBuilderArguments() {
864     $element = [];
865     $element['#lazy_builder'] = ['\Drupal\Tests\Core\Render\PlaceholdersTest::callback', 'arg1', 'arg2'];
866
867     $this->setExpectedException(\DomainException::class, 'The #lazy_builder property must have an array as a value, containing two values: the callback, and the arguments for the callback.');
868     $this->renderer->renderRoot($element);
869   }
870
871   /**
872    * @covers ::render
873    * @covers ::doRender
874    *
875    * @see testNonScalarLazybuilderCallbackContext
876    */
877   public function testScalarLazybuilderCallbackContext() {
878     $element = [];
879     $element['#lazy_builder'] = ['\Drupal\Tests\Core\Render\PlaceholdersTest::callback', [
880       'string' => 'foo',
881       'bool' => TRUE,
882       'int' => 1337,
883       'float' => 3.14,
884       'null' => NULL,
885     ]];
886
887     $result = $this->renderer->renderRoot($element);
888     $this->assertInstanceOf('\Drupal\Core\Render\Markup', $result);
889     $this->assertEquals('<p>This is a rendered placeholder!</p>', (string) $result);
890   }
891
892   /**
893    * @covers ::render
894    * @covers ::doRender
895    */
896   public function testNonScalarLazybuilderCallbackContext() {
897     $element = [];
898     $element['#lazy_builder'] = ['\Drupal\Tests\Core\Render\PlaceholdersTest::callback', [
899       'string' => 'foo',
900       'bool' => TRUE,
901       'int' => 1337,
902       'float' => 3.14,
903       'null' => NULL,
904       // array is not one of the scalar types.
905       'array' => ['hi!'],
906     ]];
907
908     $this->setExpectedException(\DomainException::class, "A #lazy_builder callback's context may only contain scalar values or NULL.");
909     $this->renderer->renderRoot($element);
910   }
911
912   /**
913    * @covers ::render
914    * @covers ::doRender
915    */
916   public function testChildrenPlusBuilder() {
917     $element = [];
918     $element['#lazy_builder'] = ['Drupal\Tests\Core\Render\RecursivePlaceholdersTest::callback', []];
919     $element['child_a']['#markup'] = 'Oh hai!';
920     $element['child_b']['#markup'] = 'kthxbai';
921
922     $this->setExpectedException(\DomainException::class, 'When a #lazy_builder callback is specified, no children can exist; all children must be generated by the #lazy_builder callback. You specified the following children: child_a, child_b.');
923     $this->renderer->renderRoot($element);
924   }
925
926   /**
927    * @covers ::render
928    * @covers ::doRender
929    */
930   public function testPropertiesPlusBuilder() {
931     $element = [];
932     $element['#lazy_builder'] = ['Drupal\Tests\Core\Render\RecursivePlaceholdersTest::callback', []];
933     $element['#llama'] = '#awesome';
934     $element['#piglet'] = '#cute';
935
936     $this->setExpectedException(\DomainException::class, 'When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: #llama, #piglet.');
937     $this->renderer->renderRoot($element);
938   }
939
940   /**
941    * @covers ::render
942    * @covers ::doRender
943    */
944   public function testCreatePlaceholderPropertyWithoutLazyBuilder() {
945     $element = [];
946     $element['#create_placeholder'] = TRUE;
947
948     $this->setExpectedException(\LogicException::class, 'When #create_placeholder is set, a #lazy_builder callback must be present as well.');
949     $this->renderer->renderRoot($element);
950   }
951
952   /**
953    * Create an element with a child and subchild. Each element has the same
954    * #lazy_builder callback, but with different contexts. They don't modify
955    * markup, only attach additional drupalSettings.
956    *
957    * @covers ::render
958    * @covers ::doRender
959    * @covers \Drupal\Core\Render\RenderCache::get
960    * @covers ::replacePlaceholders
961    */
962   public function testRenderChildrenPlaceholdersDifferentArguments() {
963     $this->setUpRequest();
964     $this->setupMemoryCache();
965     $this->cacheContextsManager->expects($this->any())
966       ->method('convertTokensToKeys')
967       ->willReturnArgument(0);
968     $this->controllerResolver->expects($this->any())
969       ->method('getControllerFromDefinition')
970       ->willReturnArgument(0);
971     $this->setupThemeManagerForDetails();
972
973     $args_1 = ['foo', TRUE];
974     $args_2 = ['bar', TRUE];
975     $args_3 = ['baz', TRUE];
976     $test_element = $this->generatePlaceholdersWithChildrenTestElement($args_1, $args_2, $args_3);
977
978     $element = $test_element;
979     $output = $this->renderer->renderRoot($element);
980     $expected_output = <<<HTML
981 <details>
982   <summary>Parent</summary>
983   <div class="details-wrapper"><details>
984   <summary>Child</summary>
985   <div class="details-wrapper">Subchild</div>
986 </details></div>
987 </details>
988 HTML;
989     $this->assertSame($expected_output, (string) $output, 'Output is not overridden.');
990     $this->assertTrue(isset($element['#printed']), 'No cache hit');
991     $this->assertSame($expected_output, (string) $element['#markup'], '#markup is not overridden.');
992     $expected_js_settings = [
993       'foo' => 'bar',
994       'dynamic_animal' => [$args_1[0] => TRUE, $args_2[0] => TRUE, $args_3[0] => TRUE],
995     ];
996     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each placeholder #lazy_builder callback exist.');
997
998     // GET request: validate cached data.
999     $cached_element = $this->memoryCache->get('simpletest:drupal_render:children_placeholders')->data;
1000     $expected_element = [
1001       '#attached' => [
1002         'drupalSettings' => [
1003           'foo' => 'bar',
1004         ],
1005         'placeholders' => [
1006           'parent-x-parent' => [
1007             '#lazy_builder' => [__NAMESPACE__ . '\\PlaceholdersTest::callback', $args_1],
1008           ],
1009           'child-x-child' => [
1010             '#lazy_builder' => [__NAMESPACE__ . '\\PlaceholdersTest::callback', $args_2],
1011           ],
1012           'subchild-x-subchild' => [
1013             '#lazy_builder' => [__NAMESPACE__ . '\\PlaceholdersTest::callback', $args_3],
1014           ],
1015         ],
1016       ],
1017       '#cache' => [
1018         'contexts' => [],
1019         'tags' => [],
1020         'max-age' => Cache::PERMANENT,
1021       ],
1022     ];
1023
1024     $dom = Html::load($cached_element['#markup']);
1025     $xpath = new \DOMXPath($dom);
1026     $parent = $xpath->query('//details/summary[text()="Parent"]')->length;
1027     $child = $xpath->query('//details/div[@class="details-wrapper"]/details/summary[text()="Child"]')->length;
1028     $subchild = $xpath->query('//details/div[@class="details-wrapper"]/details/div[@class="details-wrapper" and text()="Subchild"]')->length;
1029     $this->assertTrue($parent && $child && $subchild, 'The correct data is cached: the stored #markup is not affected by placeholder #lazy_builder callbacks.');
1030
1031     // Remove markup because it's compared above in the xpath.
1032     unset($cached_element['#markup']);
1033     $this->assertEquals($cached_element, $expected_element, 'The correct data is cached: the stored #attached properties are not affected by placeholder #lazy_builder callbacks.');
1034
1035     // GET request: #cache enabled, cache hit.
1036     $element = $test_element;
1037     $output = $this->renderer->renderRoot($element);
1038     $this->assertSame($expected_output, (string) $output, 'Output is not overridden.');
1039     $this->assertFalse(isset($element['#printed']), 'Cache hit');
1040     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each placeholder #lazy_builder callback exist.');
1041
1042     // Use the exact same element, but now unset #cache; ensure we get the same
1043     // result.
1044     unset($test_element['#cache']);
1045     $element = $test_element;
1046     $output = $this->renderer->renderRoot($element);
1047     $this->assertSame($expected_output, (string) $output, 'Output is not overridden.');
1048     $this->assertSame($expected_output, (string) $element['#markup'], '#markup is not overridden.');
1049     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #lazy_builder callback exist.');
1050   }
1051
1052   /**
1053    * Generates an element with placeholders at 3 levels.
1054    *
1055    * @param array $args_1
1056    *   The arguments for the placeholder at level 1.
1057    * @param array $args_2
1058    *   The arguments for the placeholder at level 2.
1059    * @param array $args_3
1060    *   The arguments for the placeholder at level 3.
1061    *
1062    * @return array
1063    *   The generated render array for testing.
1064    */
1065   protected function generatePlaceholdersWithChildrenTestElement(array $args_1, array $args_2, array $args_3) {
1066     $test_element = [
1067       '#type' => 'details',
1068       '#cache' => [
1069         'keys' => ['simpletest', 'drupal_render', 'children_placeholders'],
1070       ],
1071       '#title' => 'Parent',
1072       '#attached' => [
1073         'drupalSettings' => [
1074           'foo' => 'bar',
1075         ],
1076         'placeholders' => [
1077           'parent-x-parent' => [
1078             '#lazy_builder' => [ __NAMESPACE__ . '\\PlaceholdersTest::callback', $args_1],
1079           ],
1080         ],
1081       ],
1082     ];
1083     $test_element['child'] = [
1084       '#type' => 'details',
1085       '#attached' => [
1086         'placeholders' => [
1087           'child-x-child' => [
1088             '#lazy_builder' => [__NAMESPACE__ . '\\PlaceholdersTest::callback', $args_2],
1089           ],
1090         ],
1091       ],
1092       '#title' => 'Child',
1093     ];
1094     $test_element['child']['subchild'] = [
1095       '#attached' => [
1096         'placeholders' => [
1097           'subchild-x-subchild' => [
1098             '#lazy_builder' => [__NAMESPACE__ . '\\PlaceholdersTest::callback', $args_3],
1099           ],
1100         ],
1101       ],
1102       '#markup' => 'Subchild',
1103     ];
1104     return $test_element;
1105   }
1106
1107   /**
1108    * @return \Drupal\Core\Theme\ThemeManagerInterface|\PHPUnit_Framework_MockObject_Builder_InvocationMocker
1109    */
1110   protected function setupThemeManagerForDetails() {
1111     return $this->themeManager->expects($this->any())
1112       ->method('render')
1113       ->willReturnCallback(function ($theme, array $vars) {
1114         $output = <<<'EOS'
1115 <details>
1116   <summary>{{ title }}</summary>
1117   <div class="details-wrapper">{{ children }}</div>
1118 </details>
1119 EOS;
1120         $output = str_replace([
1121           '{{ title }}',
1122           '{{ children }}'
1123         ], [$vars['#title'], $vars['#children']], $output);
1124         return $output;
1125       });
1126   }
1127
1128 }
1129
1130 /**
1131  * @see \Drupal\Tests\Core\Render\RendererPlaceholdersTest::testRecursivePlaceholder()
1132  */
1133 class RecursivePlaceholdersTest {
1134
1135   /**
1136    * #lazy_builder callback; bubbles another placeholder.
1137    *
1138    * @param string $animal
1139    *   An animal.
1140    *
1141    * @return array
1142    *   A renderable array.
1143    */
1144   public static function callback($animal) {
1145     return [
1146       'another' => [
1147         '#create_placeholder' => TRUE,
1148         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', [$animal]],
1149       ],
1150     ];
1151   }
1152
1153 }