Commit ebb21d28 authored by alexpott's avatar alexpott

Issue #2450993 by Wim Leers, Fabianx, Crell, dawehner, effulgentsia: Rendered...

Issue #2450993 by Wim Leers, Fabianx, Crell, dawehner, effulgentsia: Rendered Cache Metadata created during the main controller request gets lost
parent 2dbda263
......@@ -1392,6 +1392,11 @@ services:
renderer:
class: Drupal\Core\Render\Renderer
arguments: ['@controller_resolver', '@theme.manager', '@plugin.manager.element_info', '@render_cache', '%renderer.config%']
early_rendering_controller_wrapper_subscriber:
class: Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber
arguments: ['@controller_resolver', '@renderer']
tags:
- { name: event_subscriber }
email.validator:
class: Egulias\EmailValidator\EmailValidator
......
<?php
/**
* @file
* Contains \Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber.
*/
namespace Drupal\Core\EventSubscriber;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Controller\ControllerResolverInterface;
use Drupal\Core\Render\AttachmentsInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Subscriber that wraps controllers, to handle early rendering.
*
* When controllers call drupal_render() (RendererInterface::render()) outside
* of a render context, we call that "early rendering". Controllers should
* return only render arrays, but we cannot prevent controllers from doing early
* rendering. The problem with early rendering is that the bubbleable metadata
* (cacheability & attachments) are lost.
*
* This can lead to broken pages (missing assets), stale pages (missing cache
* tags causing a page not to be invalidated) or even security problems (missing
* cache contexts causing a cached page not to be varied sufficiently).
*
* This event subscriber wraps all controller executions in a closure that sets
* up a render context. Consequently, any early rendering will have their
* bubbleable metadata (assets & cacheability) stored on that render context.
*
* If the render context is empty, then the controller either did not do any
* rendering at all, or used the RendererInterface::renderRoot() or
* ::renderPlain() methods. In that case, no bubbleable metadata is lost.
*
* If the render context is not empty, then the controller did use
* drupal_render(), and bubbleable metadata was collected. This bubbleable
* metadata is then merged onto the render array.
*
* In other words: this just exists to ease the transition to Drupal 8: it
* allows controllers that return render arrays (the majority) to still do early
* rendering. But controllers that return responses are already expected to do
* the right thing: if early rendering is detected in such a case, an exception
* is thrown.
*
* @see \Drupal\Core\Render\RendererInterface
* @see \Drupal\Core\Render\Renderer
*
* @todo Remove in Drupal 9.0.0, by disallowing early rendering.
*/
class EarlyRenderingControllerWrapperSubscriber implements EventSubscriberInterface {
/**
* The controller resolver.
*
* @var \Drupal\Core\Controller\ControllerResolverInterface
*/
protected $controllerResolver;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs a new EarlyRenderingControllerWrapperSubscriber instance.
*
* @param \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver
* The controller resolver.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
*/
public function __construct(ControllerResolverInterface $controller_resolver, RendererInterface $renderer) {
$this->controllerResolver = $controller_resolver;
$this->renderer = $renderer;
}
/**
* Ensures bubbleable metadata from early rendering is not lost.
*
* @param \Symfony\Component\HttpKernel\Event\FilterControllerEvent $event
* The controller event.
*/
public function onController(FilterControllerEvent $event) {
$controller = $event->getController();
// See \Symfony\Component\HttpKernel\HttpKernel::handleRaw().
$arguments = $this->controllerResolver->getArguments($event->getRequest(), $controller);
$event->setController(function() use ($controller, $arguments) {
return $this->wrapControllerExecutionInRenderContext($controller, $arguments);
});
}
/**
* Wraps a controller execution in a render context.
*
* @param callable $controller
* The controller to execute.
* @param array $arguments
* The arguments to pass to the controller.
*
* @return mixed
* The return value of the controller.
*
* @throws \LogicException
* When early rendering has occurred in a controller that returned a
* Response or domain object that cares about attachments or cacheability.
*
* @see \Symfony\Component\HttpKernel\HttpKernel::handleRaw()
*/
protected function wrapControllerExecutionInRenderContext($controller, array $arguments) {
$context = new RenderContext();
$response = $this->renderer->executeInRenderContext($context, function() use ($controller, $arguments) {
// Now call the actual controller, just like HttpKernel does.
return call_user_func_array($controller, $arguments);
});
// If early rendering happened, i.e. if code in the controller called
// drupal_render() outside of a render context, then the bubbleable metadata
// for that is stored in the current render context.
if (!$context->isEmpty()) {
// If a render array is returned by the controller, merge the "lost"
// bubbleable metadata.
if (is_array($response)) {
$early_rendering_bubbleable_metadata = $context->pop();
BubbleableMetadata::createFromRenderArray($response)
->merge($early_rendering_bubbleable_metadata)
->applyTo($response);
}
// If a Response or domain object is returned, and it cares about
// attachments or cacheability, then throw an exception: early rendering
// is not permitted in that case. It is the developer's responsibility
// to not use early rendering.
elseif ($response instanceof AttachmentsInterface || $response instanceof CacheableResponseInterface || $response instanceof CacheableDependencyInterface) {
throw new \LogicException(sprintf('The controller result claims to be providing relevant cache metadata, but leaked metadata was detected. Please ensure you are not rendering content too early. Returned object class: %s.', get_class($response)));
}
else {
// A Response or domain object is returned that does not care about
// attachments nor cacheability. E.g. a RedirectResponse. It is safe to
// discard any early rendering metadata.
}
}
return $response;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[KernelEvents::CONTROLLER][] = ['onController'];
return $events;
}
}
......@@ -14,6 +14,7 @@
use Drupal\Core\Render\HtmlResponse;
use Drupal\Core\Render\PageDisplayVariantSelectionEvent;
use Drupal\Core\Render\RenderCacheInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Render\RenderEvents;
use Drupal\Core\Routing\RouteMatchInterface;
......@@ -181,7 +182,9 @@ protected function prepare(array $main_content, Request $request, RouteMatchInte
// ::renderResponse().
// @todo Remove this once https://www.drupal.org/node/2359901 lands.
if (!empty($main_content)) {
$this->renderer->render($main_content, FALSE);
$this->renderer->executeInRenderContext(new RenderContext(), function() use (&$main_content) {
return $this->renderer->render($main_content, FALSE);
});
$main_content = $this->renderCache->getCacheableRenderArray($main_content) + [
'#title' => isset($main_content['#title']) ? $main_content['#title'] : NULL
];
......
<?php
/**
* @file
* Contains \Drupal\Core\Render\RenderContext.
*/
namespace Drupal\Core\Render;
/**
* The render context: a stack containing bubbleable rendering metadata.
*
* A stack of \Drupal\Core\Render\BubbleableMetadata objects.
*
* @see \Drupal\Core\Render\RendererInterface
* @see \Drupal\Core\Render\Renderer
* @see \Drupal\Core\Render\BubbleableMetadata
*
* @internal
*/
class RenderContext extends \SplStack {
/**
* Updates the current frame of 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.
*/
public function update(&$element) {
// The latest frame represents the bubbleable metadata for the subtree.
$frame = $this->pop();
// Update the frame, but also update the current element, to ensure it
// contains up-to-date information in case it gets render cached.
$updated_frame = BubbleableMetadata::createFromRenderArray($element)->merge($frame);
$updated_frame->applyTo($element);
$this->push($updated_frame);
}
/**
* 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.
*/
public function bubble() {
// If there's only one frame on the stack, then this is the root call, and
// we can't bubble up further. ::renderRoot() will reset the stack, but we
// must not reset it here to allow users of ::executeInRenderContext() to
// access the stack directly.
if ($this->count() === 1) {
return;
}
// Merge the current and the parent stack frame.
$current = $this->pop();
$parent = $this->pop();
$this->push($current->merge($parent));
}
}
......@@ -58,11 +58,24 @@ class Renderer implements RendererInterface {
protected $rendererConfig;
/**
* The stack containing bubbleable rendering metadata.
* The render context.
*
* @var \SplStack|null
* This must be static as long as some controllers rebuild the container
* during a request. This causes multiple renderer instances to co-exist
* simultaneously, render state getting lost, and therefore causing pages to
* fail to render correctly. As soon as it is guaranteed that during a request
* the same container is used, it no longer needs to be static.
*
* @var \Drupal\Core\Render\RenderContext|null
*/
protected static $context;
/**
* Whether we're currently in a ::renderRoot() call.
*
* @var bool
*/
protected static $stack;
protected $isRenderingRoot = FALSE;
/**
* Constructs a new Renderer.
......@@ -90,18 +103,29 @@ public function __construct(ControllerResolverInterface $controller_resolver, Th
* {@inheritdoc}
*/
public function renderRoot(&$elements) {
return $this->render($elements, TRUE);
// Disallow calling ::renderRoot() from within another ::renderRoot() call.
if ($this->isRenderingRoot) {
$this->isRenderingRoot = FALSE;
throw new \LogicException('A stray renderRoot() invocation is causing bubbling of attached assets to break.');
}
// Render in its own render context.
$this->isRenderingRoot = TRUE;
$output = $this->executeInRenderContext(new RenderContext(), function () use (&$elements) {
return $this->render($elements, TRUE);
});
$this->isRenderingRoot = FALSE;
return $output;
}
/**
* {@inheritdoc}
*/
public function renderPlain(&$elements) {
$current_stack = static::$stack;
$this->resetStack();
$output = $this->renderRoot($elements);
static::$stack = $current_stack;
return $output;
return $this->executeInRenderContext(new RenderContext(), function () use (&$elements) {
return $this->render($elements, TRUE);
});
}
/**
......@@ -151,16 +175,17 @@ public function render(&$elements, $is_root_call = FALSE) {
// possible that any of them throw an exception that will cause a different
// page to be rendered (e.g. throwing
// \Symfony\Component\HttpKernel\Exception\NotFoundHttpException will cause
// the 404 page to be rendered). That page might also use Renderer::render()
// but if exceptions aren't caught here, the stack will be left in an
// inconsistent state.
// Hence, catch all exceptions and reset the stack and re-throw them.
// the 404 page to be rendered). That page might also use
// Renderer::renderRoot() but if exceptions aren't caught here, it will be
// impossible to call Renderer::renderRoot() again.
// Hence, catch all exceptions, reset the isRenderingRoot property and
// re-throw exceptions.
try {
return $this->doRender($elements, $is_root_call);
}
catch (\Exception $e) {
// Reset stack and re-throw exception.
$this->resetStack();
// Mark the ::rootRender() call finished due to this exception & re-throw.
$this->isRenderingRoot = FALSE;
throw $e;
}
}
......@@ -200,10 +225,10 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
return '';
}
if (!isset(static::$stack)) {
static::$stack = new \SplStack();
if (!isset(static::$context)) {
throw new \LogicException("Render context is empty, because render() was called outside of a renderRoot() or renderPlain() call. Use renderPlain()/renderRoot() or #lazy_builder/#pre_render instead.");
}
static::$stack->push(new BubbleableMetadata());
static::$context->push(new BubbleableMetadata());
// Set the bubbleable rendering metadata that has configurable defaults, if:
// - this is the root call, to ensure that the final render array definitely
......@@ -244,10 +269,10 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
}
// The render cache item contains all the bubbleable rendering metadata
// for the subtree.
$this->updateStack($elements);
static::$context->update($elements);
// Render cache hit, so rendering is finished, all necessary info
// collected!
$this->bubbleStack();
static::$context->bubble();
return $elements['#markup'];
}
}
......@@ -345,9 +370,9 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
if (!empty($elements['#printed'])) {
// The #printed element contains all the bubbleable rendering metadata for
// the subtree.
$this->updateStack($elements);
static::$context->update($elements);
// #printed, so rendering is finished, all necessary info collected!
$this->bubbleStack();
static::$context->bubble();
return '';
}
......@@ -473,8 +498,8 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
$elements['#markup'] = $prefix . $elements['#children'] . $suffix;
// We've rendered this element (and its subtree!), now update the stack.
$this->updateStack($elements);
// We've rendered this element (and its subtree!), now update the context.
static::$context->update($elements);
// Cache the processed element if both $pre_bubbling_elements and $elements
// have the metadata necessary to generate a cache ID.
......@@ -496,13 +521,14 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
// that is handled earlier in Renderer::render().
if ($is_root_call) {
$this->replacePlaceholders($elements);
if (static::$stack->count() !== 1) {
// @todo remove as part of https://www.drupal.org/node/2511330.
if (static::$context->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!
$this->bubbleStack();
static::$context->bubble();
$elements['#printed'] = TRUE;
$elements['#markup'] = SafeMarkup::set($elements['#markup']);
......@@ -510,52 +536,24 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
}
/**
* 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() {
static::$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 = static::$stack->pop();
// Update the frame, but also update the current element, to ensure it
// contains up-to-date information in case it gets render cached.
$updated_frame = BubbleableMetadata::createFromRenderArray($element)->merge($frame);
$updated_frame->applyTo($element);
static::$stack->push($updated_frame);
}
/**
* 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.
* {@inheritdoc}
*/
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 (static::$stack->count() === 1) {
$this->resetStack();
return;
public function executeInRenderContext(RenderContext $context, callable $callable) {
// Store the current render context.
$current_context = static::$context;
// Set the provided context and call the callable, it will use that context.
static::$context = $context;
$result = $callable();
// @todo Convert to an assertion in https://www.drupal.org/node/2408013
if (static::$context->count() > 1) {
throw new \LogicException('Bubbling failed.');
}
// Merge the current and the parent stack frame.
$current = static::$stack->pop();
$parent = static::$stack->pop();
static::$stack->push($current->merge($parent));
// Restore the original render context.
static::$context = $current_context;
return $result;
}
/**
......
......@@ -22,6 +22,8 @@ interface RendererInterface {
* - system internals that are responsible for rendering the final HTML
* - render arrays for non-HTML responses, such as feeds
*
* (Cannot be executed within another render context.)
*
* @param array $elements
* The structured array describing the data to be rendered.
*
......@@ -29,6 +31,9 @@ interface RendererInterface {
* The rendered HTML.
*
* @see ::render()
*
* @throws \LogicException
* When called from inside another renderRoot() call.
*/
public function renderRoot(&$elements);
......@@ -45,9 +50,11 @@ public function renderRoot(&$elements);
* ::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
* not about the bubbling. Hence this uses a separate render context, to not
* affect the parent ::renderRoot() call.
*
* (Can be executed within another render context: it runs in isolation.)
*
* @param array $elements
* The structured array describing the data to be rendered.
*
......@@ -88,8 +95,8 @@ public function renderPlain(&$elements);
* or configuration that can affect that rendering changes.
* - Placeholders, with associated self-contained placeholder render arrays,
* for executing code to handle dynamic requirements that cannot be cached.
* A stack of \Drupal\Core\Render\BubbleableMetadata objects can be used to
* perform this bubbling.
* A render context (\Drupal\Core\Render\RenderContext) can be used to perform
* bubbling; it is a stack of \Drupal\Core\Render\BubbleableMetadata objects.
*
* Additionally, whether retrieving from cache or not, it is important to
* know all of the assets (CSS and JavaScript) required by the rendered HTML,
......@@ -103,9 +110,9 @@ public function renderPlain(&$elements);
* - If this element has already been printed (#printed = TRUE) or the user
* does not have access to it (#access = FALSE), then an empty string is
* returned.
* - If no stack data structure has been created yet, it is done now. Next,
* - If no render context is set yet, an exception is thrown. Otherwise,
* an empty \Drupal\Core\Render\BubbleableMetadata is pushed onto the
* stack.
* render context.
* - If this element has #cache defined then the cached markup for this
* element will be returned if it exists in Renderer::render()'s cache. To
* use Renderer::render() caching, set the element's #cache property to an
......@@ -299,13 +306,12 @@ public function renderPlain(&$elements);
* The rendered HTML.
*
* @throws \LogicException
* If a root call to ::render() does not result in an empty stack, this
* indicates an erroneous ::render() root call (a root call within a
* root call, which makes no sense). Therefore, a logic exception is thrown.
* When called outside of a render context. (i.e. outside of a renderRoot(),
* renderPlain() or executeInRenderContext() call.)
* @throws \Exception
* If a #pre_render callback throws an exception, it is caught to reset the
* stack used for bubbling rendering metadata, and then the exception is re-
* thrown.
* If a #pre_render callback throws an exception, it is caught to mark the
* renderer as no longer being in a root render call, if any. Then the
* exception is rethrown.
*
* @see \Drupal\Core\Render\ElementInfoManagerInterface::getInfo()
* @see \Drupal\Core\Theme\ThemeManagerInterface::render()
......@@ -315,6 +321,37 @@ public function renderPlain(&$elements);
*/
public function render(&$elements, $is_root_call = FALSE);
/**
* Executes a callable within a render context.
*
* Only for very advanced use cases. Prefer using ::renderRoot() and
* ::renderPlain() instead.
*
* All rendering must happen within a render context. Within a render context,
* all bubbleable metadata is bubbled and hence tracked. Outside of a render
* context, it would be lost. This could lead to missing assets, incorrect
* cache variations (and thus security issues), insufficient cache
* invalidations, and so on.
*
* Any and all rendering must therefore happen within a render context, and it
* is this method that provides that.
*
* @see \Drupal\Core\Render\BubbleableMetadata
*
* @param \Drupal\Core\Render\RenderContext $context
* The render context to execute the callable within.
* @param callable $callable
* The callable to execute.
* @return mixed
* The callable's return value.
*
* @see \Drupal\Core\Render\RenderContext
*
* @throws \LogicException
* In case bubbling has failed, can only happen in case of broken code.
*/
public function executeInRenderContext(RenderContext $context, callable $callable);
/**
* Merges the bubbleable rendering metadata o/t 2nd render array with the 1st.
*
......
......@@ -7,6 +7,7 @@
namespace Drupal\aggregator\Tests\Views;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Url;
use Drupal\views\Views;
use Drupal\views\Tests\ViewTestData;
......@@ -66,6 +67,9 @@ protected function setUp() {
* Tests basic aggregator_item view.
*/
public function testAggregatorItemView() {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$feed = $this->feedStorage->create(array(
'title' => $this->randomMachineName(),
'url' => 'https://www.drupal.org/',
......@@ -112,13 +116,22 @@ public function testAggregatorItemView() {
foreach ($view->result as $row) {
$iid = $view->field['iid']->getValue($row);
$expected_link = \Drupal::l($items[$iid]->getTitle(), Url::fromUri($items[$iid]->getLink(), ['absolute' => TRUE]));
$this->assertEqual($view->field['title']->advancedRender($row), $expected_link, 'Ensure the right link is generated');
$output = $renderer->executeInRenderContext(new RenderContext(), function () use ($view, $row) {
return $view->field['title']->advancedRender($row);
});
$this->assertEqual($output, $expected_link, 'Ensure the right link is generated');
$expected_author = aggregator_filter_xss($items[$iid]->getAuthor());
$this->assertEqual($view->field['author']->advancedRender($row), $expected_author, 'Ensure the author got filtered');
$output = $renderer->executeInRenderContext(new RenderContext(), function () use ($view, $row) {
return $view->field['author']->advancedRender($row);
});
$this->assertEqual($output, $expected_author, 'Ensure the author got filtered');
$expected_description = aggregator_filter_xss($items[$iid]->getDescription());
$this->assertEqual($view->field['description']->advancedRender($row), $expected_description, 'Ensure the author got filtered');
$output = $renderer->executeInRenderContext(new RenderContext(), function () use ($view, $row) {
return $view->field['description']->advancedRender($row);
});
$this->assertEqual($output, $expected_description, 'Ensure the author got filtered');
}
}
......
......@@ -8,6 +8,7 @@
namespace Drupal\comment\Tests\Views;
use Drupal\comment\Entity\Comment;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Session\AnonymousUserSession;
use Drupal\user\RoleInterface;
use Drupal\views\Views;
......@@ -58,6 +59,8 @@ protected function setUp() {
* Test comment field name.
*/
public function testCommentFieldName() {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$view = Views::getView('test_comment_field_name');
$this->executeView($view);
......@@ -85,8 +88,14 @@ public function testCommentFieldName() {
$view = Views::getView('test_comment_field_name');
$this->executeView($view);
// Test that data rendered.
$this->assertIdentical($this->comment->getFieldName(), $view->field['field_name']->advancedRender($view->result[0]));
$this->assertIdentical($this->customComment->getFieldName(), $view->field['field_name']->advancedRender($view->result[1]));
$output = $renderer->executeInRenderContext(new RenderContext(), function () use ($view) {
return $view->field['field_name']->advancedRender($view->result[0]);
});
$this->assertIdentical($this->comment->getFieldName(), $output);
$output = $renderer->executeInRenderContext(new RenderContext(), function () use ($view) {
return $view->field['field_name']->advancedRender($view->result[1]);
});
$this->assertIdentical($this->customComment->getFieldName(), $output);
}
}
......@@ -7,6 +7,7 @@
namespace Drupal\file\Tests\Views;
use Drupal\Core\Render\RenderContext;
use Drupal\file\Entity\File;
use Drupal\views\Views;
use Drupal\views\Tests\ViewUnitTestBase;
......@@ -69,17 +70,22 @@ protected function setUp() {
* Tests file extension views field handler extension_detect_tar option.
*/
public function testFileExtensionTarOption() {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$view = Views::getView('file_extension_view');
$view->setDisplay();
$this->executeView($view);
// Test without the tar option.
$this->assertEqual($view->field['extension']->advancedRender($view->result[0]), 'png');
$this->assertEqual($view->field['extension']->advancedRender($view->result[1]), 'tar');
$this->assertEqual($view->field['extension']->advancedRender($view->result[2]), 'gz');
$this->assertEqual($view->field['extension']->advancedRender($view->result[3]), '');
// Test with the tar option.
$renderer->executeInRenderContext(new RenderContext(), function () use ($view) {
$this->assertEqual($view->field['extension']->advancedRender($view->result[0]), 'png');
$this->assertEqual($view->field['extension']->advancedRender($view->result[1]), 'tar');
$this->assertEqual($view->field['extension']->advancedRender($view->result[2]), 'gz');
$this->assertEqual($view->field['extension']->advancedRender($view->result[3]), '');
});
// Test with the tar option.
$view = Views::getView('file_extension_view');