diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt index 63748c27bd717b4a3215bf8ef13fff7fba53095d..69802ff981fe19b88e3ec027635d1ddc7b37e7d1 100644 --- a/core/MAINTAINERS.txt +++ b/core/MAINTAINERS.txt @@ -261,6 +261,10 @@ Basic Auth module - Klaus Purer 'klausi' https://www.drupal.org/u/klausi - Juampy Novillo Requena 'juampy' https://www.drupal.org/u/juampy +BigPipe module +- Wim Leers 'Wim Leers' https://www.drupal.org/u/wim-leers +- Fabian Franz 'Fabianx' https://www.drupal.org/u/fabianx + Block module - Tim Plunkett 'tim.plunkett' https://www.drupal.org/u/tim.plunkett - Ben Dougherty 'benjy' https://www.drupal.org/u/benjy diff --git a/core/composer.json b/core/composer.json index e8120c604d08bc258ba984104ed2bbeadcfb4032..dbe1db9238b7975b1cba3457a3f0d407c758649c 100644 --- a/core/composer.json +++ b/core/composer.json @@ -48,6 +48,7 @@ "drupal/bartik": "self.version", "drupal/ban": "self.version", "drupal/basic_auth": "self.version", + "drupal/big_pipe": "self.version", "drupal/block": "self.version", "drupal/block_content": "self.version", "drupal/book": "self.version", diff --git a/core/modules/big_pipe/big_pipe.info.yml b/core/modules/big_pipe/big_pipe.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..0108e1f690b0f6b47f16b8a9da70485109ce2bd8 --- /dev/null +++ b/core/modules/big_pipe/big_pipe.info.yml @@ -0,0 +1,6 @@ +name: BigPipe +type: module +description: 'Sends pages in a way that allows browsers to show them much faster. Uses the BigPipe technique.' +package: Core (Experimental) +version: VERSION +core: 8.x diff --git a/core/modules/big_pipe/big_pipe.libraries.yml b/core/modules/big_pipe/big_pipe.libraries.yml new file mode 100644 index 0000000000000000000000000000000000000000..6da49b78e0529d0e70f35dc92817996226c16209 --- /dev/null +++ b/core/modules/big_pipe/big_pipe.libraries.yml @@ -0,0 +1,11 @@ +big_pipe: + version: VERSION + js: + js/big_pipe.js: {} + drupalSettings: + bigPipePlaceholderIds: [] + dependencies: + - core/jquery + - core/jquery.once + - core/drupal.ajax + - core/drupalSettings diff --git a/core/modules/big_pipe/big_pipe.module b/core/modules/big_pipe/big_pipe.module new file mode 100644 index 0000000000000000000000000000000000000000..1970c27d988c8a752990542f330740ee3075b4b1 --- /dev/null +++ b/core/modules/big_pipe/big_pipe.module @@ -0,0 +1,66 @@ +<?php + +/** + * @file + * Adds BigPipe no-JS detection. + */ + +use Drupal\big_pipe\Render\Placeholder\BigPipeStrategy; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Url; + +/** + * Implements hook_help(). + */ +function big_pipe_help($route_name, RouteMatchInterface $route_match) { + switch ($route_name) { + case 'help.page.big_pipe': + $output = '<h3>' . t('About') . '</h3>'; + $output .= '<p>' . t('The BigPipe module sends pages with dynamic content in a way that allows browsers to show them much faster. For more information, see the <a href=":big_pipe-documentation">online documentation for the BigPipe module</a>.', [':big_pipe-documentation' => 'https://www.drupal.org/documentation/modules/big_pipe']) . '</p>'; + $output .= '<h3>' . t('Uses') . '</h3>'; + $output .= '<dl>'; + $output .= '<dt>' . t('Speeding up your site') . '</dt>'; + $output .= '<dd>' . t('The module requires no configuration. Every part of the page contains metadata that allows BigPipe to figure this out on its own.') . '</dd>'; + $output .= '</dl>'; + + return $output; + } +} + +/** + * Implements hook_page_attachments(). + * + * @see \Drupal\big_pipe\Controller\BigPipeController::setNoJsCookie() + */ +function big_pipe_page_attachments(array &$page) { + // Routes that don't use BigPipe also don't need no-JS detection. + if (\Drupal::routeMatch()->getRouteObject()->getOption('_no_big_pipe')) { + return; + } + + $request = \Drupal::request(); + // BigPipe is only used when there is an actual session, so only add the no-JS + // detection when there actually is a session. + // @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy. + $session_exists = \Drupal::service('session_configuration')->hasSession($request); + $page['#cache']['contexts'][] = 'session.exists'; + // Only do the no-JS detection while we don't know if there's no JS support: + // avoid endless redirect loops. + $has_big_pipe_nojs_cookie = $request->cookies->has(BigPipeStrategy::NOJS_COOKIE); + $page['#cache']['contexts'][] = 'cookies:' . BigPipeStrategy::NOJS_COOKIE; + if ($session_exists && !$has_big_pipe_nojs_cookie) { + $page['#attached']['html_head'][] = [ + [ + // Redirect through a 'Refresh' meta tag if JavaScript is disabled. + '#tag' => 'meta', + '#noscript' => TRUE, + '#attributes' => [ + 'http-equiv' => 'Refresh', + // @todo: Switch to Url::fromRoute() once https://www.drupal.org/node/2589967 is resolved. + 'content' => '0; URL=' . Url::fromUri('internal:/big_pipe/no-js', ['query' => \Drupal::service('redirect.destination')->getAsArray()])->toString(), + ], + ], + 'big_pipe_detect_nojs', + ]; + } +} diff --git a/core/modules/big_pipe/big_pipe.routing.yml b/core/modules/big_pipe/big_pipe.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..c7981dc8a5d00ec3fa0104298f71e363dc82aac3 --- /dev/null +++ b/core/modules/big_pipe/big_pipe.routing.yml @@ -0,0 +1,9 @@ +big_pipe.nojs: + path: '/big_pipe/no-js' + defaults: + _controller: '\Drupal\big_pipe\Controller\BigPipeController:setNoJsCookie' + _title: 'BigPipe no-JS check' + options: + no_cache: TRUE + requirements: + _access: 'TRUE' diff --git a/core/modules/big_pipe/big_pipe.services.yml b/core/modules/big_pipe/big_pipe.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..b06255a70a63a0487966a41f60448a66c21bae1f --- /dev/null +++ b/core/modules/big_pipe/big_pipe.services.yml @@ -0,0 +1,25 @@ +services: + html_response.big_pipe_subscriber: + class: Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber + tags: + - { name: event_subscriber } + arguments: ['@big_pipe'] + placeholder_strategy.big_pipe: + class: Drupal\big_pipe\Render\Placeholder\BigPipeStrategy + arguments: ['@session_configuration', '@request_stack', '@current_route_match'] + tags: + - { name: placeholder_strategy, priority: 0 } + big_pipe: + class: Drupal\big_pipe\Render\BigPipe + arguments: ['@renderer', '@session', '@request_stack', '@http_kernel', '@event_dispatcher'] + html_response.attachments_processor.big_pipe: + public: false + class: \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor + decorates: html_response.attachments_processor + decoration_inner_name: html_response.attachments_processor.original + arguments: ['@html_response.attachments_processor.original', '@asset.resolver', '@config.factory', '@asset.css.collection_renderer', '@asset.js.collection_renderer', '@request_stack', '@renderer', '@module_handler'] + + route_subscriber.no_big_pipe: + class: Drupal\big_pipe\EventSubscriber\NoBigPipeRouteAlterSubscriber + tags: + - { name: event_subscriber } diff --git a/core/modules/big_pipe/js/big_pipe.js b/core/modules/big_pipe/js/big_pipe.js new file mode 100644 index 0000000000000000000000000000000000000000..2deefec3437d14611bcc2181adfc710399e13a6e --- /dev/null +++ b/core/modules/big_pipe/js/big_pipe.js @@ -0,0 +1,107 @@ +/** + * @file + * Renders BigPipe placeholders using Drupal's Ajax system. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Executes Ajax commands in <script type="application/json"> tag. + * + * These Ajax commands replace placeholders with HTML and load missing CSS/JS. + * + * @param {number} index + * Current index. + * @param {HTMLScriptElement} placeholderReplacement + * Script tag created by BigPipe. + */ + function bigPipeProcessPlaceholderReplacement(index, placeholderReplacement) { + var placeholderId = placeholderReplacement.getAttribute('data-big-pipe-replacement-for-placeholder-with-id'); + var content = this.textContent.trim(); + // Ignore any placeholders that are not in the known placeholder list. Used + // to avoid someone trying to XSS the site via the placeholdering mechanism. + if (typeof drupalSettings.bigPipePlaceholderIds[placeholderId] !== 'undefined') { + // If we try to parse the content too early (when the JSON containing Ajax + // commands is still arriving), textContent will be empty which will cause + // JSON.parse() to fail. Remove once so that it can be processed again + // later. + // @see bigPipeProcessDocument() + if (content === '') { + $(this).removeOnce('big-pipe'); + } + else { + var response = JSON.parse(content); + // Create a Drupal.Ajax object without associating an element, a + // progress indicator or a URL. + var ajaxObject = Drupal.ajax({ + url: '', + base: false, + element: false, + progress: false + }); + // Then, simulate an AJAX response having arrived, and let the Ajax + // system handle it. + ajaxObject.success(response, 'success'); + } + } + } + + /** + * Processes a streamed HTML document receiving placeholder replacements. + * + * @param {HTMLDocument} context + * The HTML document containing <script type="application/json"> tags + * generated by BigPipe. + * + * @return {bool} + * Returns true when processing has been finished and a stop signal has been + * found. + */ + function bigPipeProcessDocument(context) { + // Make sure we have BigPipe-related scripts before processing further. + if (!context.querySelector('script[data-big-pipe-event="start"]')) { + return false; + } + + $(context).find('script[data-big-pipe-replacement-for-placeholder-with-id]') + .once('big-pipe') + .each(bigPipeProcessPlaceholderReplacement); + + // If we see the stop signal, clear the timeout: all placeholder + // replacements are guaranteed to be received and processed. + if (context.querySelector('script[data-big-pipe-event="stop"]')) { + if (timeoutID) { + clearTimeout(timeoutID); + } + return true; + } + + return false; + } + + function bigPipeProcess() { + timeoutID = setTimeout(function () { + if (!bigPipeProcessDocument(document)) { + bigPipeProcess(); + } + }, interval); + } + + var interval = 200; + // The internal ID to contain the watcher service. + var timeoutID; + + bigPipeProcess(); + + // If something goes wrong, make sure everything is cleaned up and has had a + // chance to be processed with everything loaded. + $(window).on('load', function () { + if (timeoutID) { + clearTimeout(timeoutID); + } + bigPipeProcessDocument(document); + }); + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/big_pipe/src/Controller/BigPipeController.php b/core/modules/big_pipe/src/Controller/BigPipeController.php new file mode 100644 index 0000000000000000000000000000000000000000..282a634e623addaecef3cd0dc99a09566d650e8b --- /dev/null +++ b/core/modules/big_pipe/src/Controller/BigPipeController.php @@ -0,0 +1,64 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe\Controller\BigPipeController. + */ + +namespace Drupal\big_pipe\Controller; + +use Drupal\big_pipe\Render\Placeholder\BigPipeStrategy; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Routing\LocalRedirectResponse; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; + +/** + * Returns responses for BigPipe module routes. + */ +class BigPipeController { + + /** + * Sets a BigPipe no-JS cookie, redirects back to the original location. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * + * @return \Drupal\Core\Routing\LocalRedirectResponse + * A response that sets the no-JS cookie and redirects back to the original + * location. + * + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * Thrown when the no-JS cookie is already set or when there is no session. + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + * Thrown when the original location is missing, i.e. when no 'destination' + * query argument is set. + * + * @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy + */ + public function setNoJsCookie(Request $request) { + // This controller may only be accessed when the browser does not support + // JavaScript. It is accessed automatically when that's the case thanks to + // big_pipe_page_attachments(). When this controller is executed, deny + // access when either: + // - the no-JS cookie is already set: this indicates a redirect loop, since + // the cookie was already set, yet the user is executing this controller; + // - there is no session, in which case BigPipe is not enabled anyway, so it + // is pointless to set this cookie. + if ($request->cookies->has(BigPipeStrategy::NOJS_COOKIE) || $request->getSession() === NULL) { + throw new AccessDeniedHttpException(); + } + + if (!$request->query->has('destination')) { + throw new HttpException(400, 'The original location is missing.'); + } + + $response = new LocalRedirectResponse($request->query->get('destination')); + $response->headers->setCookie(new Cookie(BigPipeStrategy::NOJS_COOKIE, TRUE)); + $response->addCacheableDependency((new CacheableMetadata())->addCacheContexts(['cookies:' . BigPipeStrategy::NOJS_COOKIE, 'session.exists'])); + return $response; + } + +} diff --git a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..31d1278d74c238eb5d43f8da81e28eb17859a00e --- /dev/null +++ b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php @@ -0,0 +1,161 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber. + */ + +namespace Drupal\big_pipe\EventSubscriber; + +use Drupal\Core\Render\HtmlResponse; +use Drupal\big_pipe\Render\BigPipeInterface; +use Drupal\big_pipe\Render\BigPipeResponse; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Response subscriber to replace the HtmlResponse with a BigPipeResponse. + * + * @see \Drupal\big_pipe\Render\BigPipeInterface + * + * @todo Refactor once https://www.drupal.org/node/2577631 lands. + */ +class HtmlResponseBigPipeSubscriber implements EventSubscriberInterface { + + /** + * Attribute name of the BigPipe response eligibility test result. + * + * @see onRespondEarly() + * @see onRespond() + */ + const ATTRIBUTE_ELIGIBLE = '_big_pipe_eligible'; + + /** + * The BigPipe service. + * + * @var \Drupal\big_pipe\Render\BigPipeInterface + */ + protected $bigPipe; + + /** + * Constructs a HtmlResponseBigPipeSubscriber object. + * + * @param \Drupal\big_pipe\Render\BigPipeInterface $big_pipe + * The BigPipe service. + */ + public function __construct(BigPipeInterface $big_pipe) { + $this->bigPipe = $big_pipe; + } + + /** + * Adds markers to the response necessary for the BigPipe render strategy. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The event to process. + */ + public function onRespondEarly(FilterResponseEvent $event) { + // It does not make sense to have BigPipe responses for subrequests. BigPipe + // is never useful internally in Drupal, only externally towards end users. + $response = $event->getResponse(); + $is_eligible = $event->isMasterRequest() && $response instanceof HtmlResponse; + $event->getRequest()->attributes->set(self::ATTRIBUTE_ELIGIBLE, $is_eligible); + if (!$is_eligible) { + return; + } + + // Wrap the scripts_bottom placeholder with a marker before and after, + // because \Drupal\big_pipe\Render\BigPipe needs to be able to extract that + // markup if there are no-JS BigPipe placeholders. + // @see \Drupal\big_pipe\Render\BigPipe::sendPreBody() + $attachments = $response->getAttachments(); + if (isset($attachments['html_response_attachment_placeholders']['scripts_bottom'])) { + $scripts_bottom_placeholder = $attachments['html_response_attachment_placeholders']['scripts_bottom']; + $content = $response->getContent(); + $content = str_replace($scripts_bottom_placeholder, '<drupal-big-pipe-scripts-bottom-marker>' . $scripts_bottom_placeholder . '<drupal-big-pipe-scripts-bottom-marker>', $content); + $response->setContent($content); + } + } + + /** + * Transforms a HtmlResponse to a BigPipeResponse. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The event to process. + */ + public function onRespond(FilterResponseEvent $event) { + // Early return if this response was already found to not be eligible. + // @see onRespondEarly() + if (!$event->getRequest()->attributes->get(self::ATTRIBUTE_ELIGIBLE)) { + return; + } + + $response = $event->getResponse(); + $attachments = $response->getAttachments(); + + // If there are no no-JS BigPipe placeholders, unwrap the scripts_bottom + // markup. + // @see onRespondEarly() + // @see \Drupal\big_pipe\Render\BigPipe::sendPreBody() + if (empty($attachments['big_pipe_nojs_placeholders'])) { + $content = $response->getContent(); + $content = str_replace('<drupal-big-pipe-scripts-bottom-marker>', '', $content); + $response->setContent($content); + } + + // If there are neither BigPipe placeholders nor no-JS BigPipe placeholders, + // there isn't anything dynamic in this response, and we can return early: + // there is no point in sending this response using BigPipe. + if (empty($attachments['big_pipe_placeholders']) && empty($attachments['big_pipe_nojs_placeholders'])) { + return; + } + + $big_pipe_response = new BigPipeResponse(); + $big_pipe_response->setBigPipeService($this->bigPipe); + + // Clone the HtmlResponse's data into the new BigPipeResponse. + $big_pipe_response->headers = clone $response->headers; + $big_pipe_response + ->setStatusCode($response->getStatusCode()) + ->setContent($response->getContent()) + ->setAttachments($attachments) + ->addCacheableDependency($response->getCacheableMetadata()); + + // A BigPipe response can never be cached, because it is intended for a + // single user. + // @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1 + $big_pipe_response->setPrivate(); + + // Inform surrogates how they should handle BigPipe responses: + // - "no-store" specifies that the response should not be stored in cache; + // it is only to be used for the original request + // - "content" identifies what processing surrogates should perform on the + // response before forwarding it. We send, "BigPipe/1.0", which surrogates + // should not process at all, and in fact, they should not even buffer it + // at all. + // @see http://www.w3.org/TR/edge-arch/ + $big_pipe_response->headers->set('Surrogate-Control', 'no-store, content="BigPipe/1.0"'); + + // Add header to support streaming on NGINX + php-fpm (nginx >= 1.5.6). + $big_pipe_response->headers->set('X-Accel-Buffering', 'no'); + + $event->setResponse($big_pipe_response); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Run after HtmlResponsePlaceholderStrategySubscriber (priority 5), i.e. + // after BigPipeStrategy has been applied, but before normal (priority 0) + // response subscribers have been applied, because by then it'll be too late + // to transform it into a BigPipeResponse. + $events[KernelEvents::RESPONSE][] = ['onRespondEarly', 3]; + + // Run as the last possible subscriber. + $events[KernelEvents::RESPONSE][] = ['onRespond', -10000]; + + return $events; + } + +} diff --git a/core/modules/big_pipe/src/EventSubscriber/NoBigPipeRouteAlterSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/NoBigPipeRouteAlterSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..973c2c53fba17c69120a0f6ec2919a8d5f48e9bf --- /dev/null +++ b/core/modules/big_pipe/src/EventSubscriber/NoBigPipeRouteAlterSubscriber.php @@ -0,0 +1,53 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe\EventSubscriber\NoBigPipeRouteAlterSubscriber. + */ + +namespace Drupal\big_pipe\EventSubscriber; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Drupal\Core\Routing\RoutingEvents; +use Drupal\Core\Routing\RouteBuildEvent; + +/** + * Sets the '_no_big_pipe' option on select routes. + */ +class NoBigPipeRouteAlterSubscriber implements EventSubscriberInterface { + + /** + * Alters select routes to have the '_no_big_pipe' option. + * + * @param \Drupal\Core\Routing\RouteBuildEvent $event + * The event to process. + */ + public function onRoutingRouteAlterSetNoBigPipe(RouteBuildEvent $event) { + $no_big_pipe_routes = [ + // The batch system uses a <meta> refresh to work without JavaScript. + 'system.batch_page.html', + // When a user would install the BigPipe module using a browser and with + // JavaScript disabled, the first response contains the status messages + // for installing a module, but then the BigPipe no-JS redirect occurs, + // which then causes the user to not see those status messages. + // @see https://www.drupal.org/node/2469431#comment-10901944 + 'system.modules_list', + ]; + + $route_collection = $event->getRouteCollection(); + foreach ($no_big_pipe_routes as $excluded_route) { + if ($route = $route_collection->get($excluded_route)) { + $route->setOption('_no_big_pipe', TRUE); + } + } + } + + /** + * {@inheritdoc} + */ + static function getSubscribedEvents() { + $events[RoutingEvents::ALTER][] = ['onRoutingRouteAlterSetNoBigPipe']; + return $events; + } + +} diff --git a/core/modules/big_pipe/src/Render/BigPipe.php b/core/modules/big_pipe/src/Render/BigPipe.php new file mode 100644 index 0000000000000000000000000000000000000000..cb30d2a2dde05595a1885a25ec4d4fb9cbc372fd --- /dev/null +++ b/core/modules/big_pipe/src/Render/BigPipe.php @@ -0,0 +1,446 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe\Render\BigPipe. + */ + +namespace Drupal\big_pipe\Render; + +use Drupal\Component\Utility\Crypt; +use Drupal\Component\Utility\Html; +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\ReplaceCommand; +use Drupal\Core\Asset\AttachedAssets; +use Drupal\Core\Asset\AttachedAssetsInterface; +use Drupal\Core\Render\HtmlResponse; +use Drupal\Core\Render\RendererInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * The default BigPipe service. + */ +class BigPipe implements BigPipeInterface { + + /** + * The renderer. + * + * @var \Drupal\Core\Render\RendererInterface + */ + protected $renderer; + + /** + * The session. + * + * @var \Symfony\Component\HttpFoundation\Session\SessionInterface + */ + protected $session; + + /** + * The request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + + /** + * The HTTP kernel. + * + * @var \Symfony\Component\HttpKernel\HttpKernelInterface + */ + protected $httpKernel; + + /** + * The event dispatcher. + * + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + protected $eventDispatcher; + + /** + * Constructs a new BigPipe class. + * + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer. + * @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session + * The session. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The request stack. + * @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel + * The HTTP kernel. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher + * The event dispatcher. + */ + public function __construct(RendererInterface $renderer, SessionInterface $session, RequestStack $request_stack, HttpKernelInterface $http_kernel, EventDispatcherInterface $event_dispatcher) { + $this->renderer = $renderer; + $this->session = $session; + $this->requestStack = $request_stack; + $this->httpKernel = $http_kernel; + $this->eventDispatcher = $event_dispatcher; + } + + /** + * {@inheritdoc} + */ + public function sendContent($content, array $attachments) { + // First, gather the BigPipe placeholders that must be replaced. + $placeholders = isset($attachments['big_pipe_placeholders']) ? $attachments['big_pipe_placeholders'] : []; + $nojs_placeholders = isset($attachments['big_pipe_nojs_placeholders']) ? $attachments['big_pipe_nojs_placeholders'] : []; + + // BigPipe sends responses using "Transfer-Encoding: chunked". To avoid + // sending already-sent assets, it is necessary to track cumulative assets + // from all previously rendered/sent chunks. + // @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.41 + $cumulative_assets = AttachedAssets::createFromRenderArray(['#attached' => $attachments]); + $cumulative_assets->setAlreadyLoadedLibraries($attachments['library']); + + // The content in the placeholders may depend on the session, and by the + // time the response is sent (see index.php), the session is already closed. + // Reopen it for the duration that we are rendering placeholders. + $this->session->start(); + + list($pre_body, $post_body) = explode('</body>', $content, 2); + $this->sendPreBody($pre_body, $nojs_placeholders, $cumulative_assets); + $this->sendPlaceholders($placeholders, $this->getPlaceholderOrder($pre_body), $cumulative_assets); + $this->sendPostBody($post_body); + + // Close the session again. + $this->session->save(); + + return $this; + } + + /** + * Sends everything until just before </body>. + * + * @param string $pre_body + * The HTML response's content until the closing </body> tag. + * @param array $no_js_placeholders + * The no-JS BigPipe placeholders. + * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets + * The cumulative assets sent so far; to be updated while rendering no-JS + * BigPipe placeholders. + */ + protected function sendPreBody($pre_body, array $no_js_placeholders, AttachedAssetsInterface $cumulative_assets) { + // If there are no no-JS BigPipe placeholders, we can send the pre-</body> + // part of the page immediately. + if (empty($no_js_placeholders)) { + print $pre_body; + flush(); + return; + } + + // Extract the scripts_bottom markup: the no-JS BigPipe placeholders that we + // will render may attach additional asset libraries, and if so, it will be + // necessary to re-render scripts_bottom. + list($pre_scripts_bottom, $scripts_bottom, $post_scripts_bottom) = explode('<drupal-big-pipe-scripts-bottom-marker>', $pre_body, 3); + $cumulative_assets_initial = clone $cumulative_assets; + + $this->sendNoJsPlaceholders($pre_scripts_bottom . $post_scripts_bottom, $no_js_placeholders, $cumulative_assets); + + // If additional asset libraries or drupalSettings were attached by any of + // the placeholders, then we need to re-render scripts_bottom. + if ($cumulative_assets_initial != $cumulative_assets) { + // Create a new HtmlResponse. Ensure the CSS and (non-bottom) JS is sent + // before the HTML they're associated with. + // @see \Drupal\Core\Render\HtmlResponseSubscriber + // @see template_preprocess_html() + $js_bottom_placeholder = '<nojs-bigpipe-placeholder-scripts-bottom-placeholder token="' . Crypt::randomBytesBase64(55) . '">'; + + $html_response = new HtmlResponse(); + $html_response->setContent([ + '#markup' => BigPipeMarkup::create($js_bottom_placeholder), + '#attached' => [ + 'drupalSettings' => $cumulative_assets->getSettings(), + 'library' => $cumulative_assets->getAlreadyLoadedLibraries(), + 'html_response_attachment_placeholders' => [ + 'scripts_bottom' => $js_bottom_placeholder, + ], + ], + ]); + $html_response->getCacheableMetadata()->setCacheMaxAge(0); + + // Push a fake request with the asset libraries loaded so far and dispatch + // KernelEvents::RESPONSE event. This results in the attachments for the + // HTML response being processed by HtmlResponseAttachmentsProcessor and + // hence the HTML to load the bottom JavaScript can be rendered. + $fake_request = $this->requestStack->getMasterRequest()->duplicate(); + $html_response = $this->filterEmbeddedResponse($fake_request, $html_response); + $scripts_bottom = $html_response->getContent(); + } + + print $scripts_bottom; + flush(); + } + + /** + * Sends no-JS BigPipe placeholders' replacements as embedded HTML responses. + * + * @param string $html + * HTML markup. + * @param array $no_js_placeholders + * Associative array; the no-JS BigPipe placeholders. Keys are the BigPipe + * selectors. + * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets + * The cumulative assets sent so far; to be updated while rendering no-JS + * BigPipe placeholders. + */ + protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAssetsInterface $cumulative_assets) { + // Split the HTML on every no-JS placeholder string. + $prepare_for_preg_split = function ($placeholder_string) { + return '(' . preg_quote($placeholder_string, '/') . ')'; + }; + $preg_placeholder_strings = array_map($prepare_for_preg_split, array_keys($no_js_placeholders)); + $fragments = preg_split('/' . implode('|', $preg_placeholder_strings) . '/', $html, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); + + foreach ($fragments as $fragment) { + // If the fragment isn't one of the no-JS placeholders, it is the HTML in + // between placeholders and it must be printed & flushed immediately. The + // rest of the logic in the loop handles the placeholders. + if (!isset($no_js_placeholders[$fragment])) { + print $fragment; + flush(); + continue; + } + + $placeholder = $fragment; + assert('isset($no_js_placeholders[$placeholder])'); + $token = Crypt::randomBytesBase64(55); + + // Render the placeholder, but include the cumulative settings assets, so + // we can calculate the overall settings for the entire page. + $placeholder_plus_cumulative_settings = [ + 'placeholder' => $no_js_placeholders[$placeholder], + 'cumulative_settings_' . $token => [ + '#attached' => [ + 'drupalSettings' => $cumulative_assets->getSettings(), + ], + ], + ]; + $elements = $this->renderPlaceholder($placeholder, $placeholder_plus_cumulative_settings); + + // Create a new HtmlResponse. Ensure the CSS and (non-bottom) JS is sent + // before the HTML they're associated with. In other words: ensure the + // critical assets for this placeholder's markup are loaded first. + // @see \Drupal\Core\Render\HtmlResponseSubscriber + // @see template_preprocess_html() + $css_placeholder = '<nojs-bigpipe-placeholder-styles-placeholder token="' . $token . '">'; + $js_placeholder = '<nojs-bigpipe-placeholder-scripts-placeholder token="' . $token . '">'; + $elements['#markup'] = BigPipeMarkup::create($css_placeholder . $js_placeholder . (string) $elements['#markup']); + $elements['#attached']['html_response_attachment_placeholders']['styles'] = $css_placeholder; + $elements['#attached']['html_response_attachment_placeholders']['scripts'] = $js_placeholder; + + $html_response = new HtmlResponse(); + $html_response->setContent($elements); + $html_response->getCacheableMetadata()->setCacheMaxAge(0); + + // Push a fake request with the asset libraries loaded so far and dispatch + // KernelEvents::RESPONSE event. This results in the attachments for the + // HTML response being processed by HtmlResponseAttachmentsProcessor and + // hence: + // - the HTML to load the CSS can be rendered. + // - the HTML to load the JS (at the top) can be rendered. + $fake_request = $this->requestStack->getMasterRequest()->duplicate(); + $fake_request->request->set('ajax_page_state', ['libraries' => implode(',', $cumulative_assets->getAlreadyLoadedLibraries())]); + $html_response = $this->filterEmbeddedResponse($fake_request, $html_response); + + // Send this embedded HTML response. + print $html_response->getContent(); + flush(); + + // Another placeholder was rendered and sent, track the set of asset + // libraries sent so far. Any new settings also need to be tracked, so + // they can be sent in ::sendPreBody(). + $cumulative_assets->setAlreadyLoadedLibraries(array_merge($cumulative_assets->getAlreadyLoadedLibraries(), $html_response->getAttachments()['library'])); + $cumulative_assets->setSettings($html_response->getAttachments()['drupalSettings']); + } + } + + /** + * Sends BigPipe placeholders' replacements as embedded AJAX responses. + * + * @param array $placeholders + * Associative array; the BigPipe placeholders. Keys are the BigPipe + * placeholder IDs. + * @param array $placeholder_order + * Indexed array; the order in which the BigPipe placeholders must be sent. + * Values are the BigPipe placeholder IDs. (These values correspond to keys + * in $placeholders.) + * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets + * The cumulative assets sent so far; to be updated while rendering BigPipe + * placeholders. + */ + protected function sendPlaceholders(array $placeholders, array $placeholder_order, AttachedAssetsInterface $cumulative_assets) { + // Return early if there are no BigPipe placeholders to send. + if (empty($placeholders)) { + return; + } + + // Send the start signal. + print "\n"; + print '<script type="application/json" data-big-pipe-event="start"></script>' . "\n"; + flush(); + + // A BigPipe response consists of a HTML response plus multiple embedded + // AJAX responses. To process the attachments of those AJAX responses, we + // need a fake request that is identical to the master request, but with + // one change: it must have the right Accept header, otherwise the work- + // around for a bug in IE9 will cause not JSON, but <textarea>-wrapped JSON + // to be returned. + // @see \Drupal\Core\EventSubscriber\AjaxResponseSubscriber::onResponse() + $fake_request = $this->requestStack->getMasterRequest()->duplicate(); + $fake_request->headers->set('Accept', 'application/json'); + + foreach ($placeholder_order as $placeholder_id) { + if (!isset($placeholders[$placeholder_id])) { + continue; + } + + // Render the placeholder. + $placeholder_render_array = $placeholders[$placeholder_id]; + $elements = $this->renderPlaceholder($placeholder_id, $placeholder_render_array); + + // Create a new AjaxResponse. + $ajax_response = new AjaxResponse(); + // JavaScript's querySelector automatically decodes HTML entities in + // attributes, so we must decode the entities of the current BigPipe + // placeholder ID (which has HTML entities encoded since we use it to find + // the placeholders). + $big_pipe_js_placeholder_id = Html::decodeEntities($placeholder_id); + $ajax_response->addCommand(new ReplaceCommand(sprintf('[data-big-pipe-placeholder-id="%s"]', $big_pipe_js_placeholder_id), $elements['#markup'])); + $ajax_response->setAttachments($elements['#attached']); + + // Push a fake request with the asset libraries loaded so far and dispatch + // KernelEvents::RESPONSE event. This results in the attachments for the + // AJAX response being processed by AjaxResponseAttachmentsProcessor and + // hence: + // - the necessary AJAX commands to load the necessary missing asset + // libraries and updated AJAX page state are added to the AJAX response + // - the attachments associated with the response are finalized, which + // allows us to track the total set of asset libraries sent in the + // initial HTML response plus all embedded AJAX responses sent so far. + $fake_request->request->set('ajax_page_state', ['libraries' => implode(',', $cumulative_assets->getAlreadyLoadedLibraries())] + $cumulative_assets->getSettings()['ajaxPageState']); + $ajax_response = $this->filterEmbeddedResponse($fake_request, $ajax_response); + + // Send this embedded AJAX response. + $json = $ajax_response->getContent(); + $output = <<<EOF + <script type="application/json" data-big-pipe-replacement-for-placeholder-with-id="$placeholder_id"> + $json + </script> +EOF; + print $output; + flush(); + + // Another placeholder was rendered and sent, track the set of asset + // libraries sent so far. Any new settings are already sent; we don't need + // to track those. + if (isset($ajax_response->getAttachments()['drupalSettings']['ajaxPageState']['libraries'])) { + $cumulative_assets->setAlreadyLoadedLibraries(explode(',', $ajax_response->getAttachments()['drupalSettings']['ajaxPageState']['libraries'])); + } + } + + // Send the stop signal. + print '<script type="application/json" data-big-pipe-event="stop"></script>' . "\n"; + flush(); + } + + /** + * Filters the given embedded response, using the cumulative AJAX page state. + * + * @param \Symfony\Component\HttpFoundation\Request $fake_request + * A fake subrequest that contains the cumulative AJAX page state of the + * HTML document and all preceding Embedded HTML or AJAX responses. + * @param \Symfony\Component\HttpFoundation\Response|\Drupal\Core\Render\HtmlResponse|\Drupal\Core\Ajax\AjaxResponse $embedded_response + * Either a HTML response or an AJAX response that will be embedded in the + * overall HTML response. + * + * @return \Symfony\Component\HttpFoundation\Response + * The filtered response, which will load only the assets that $fake_request + * did not indicate to already have been loaded, plus the updated cumulative + * AJAX page state. + */ + protected function filterEmbeddedResponse(Request $fake_request, Response $embedded_response) { + assert('$embedded_response instanceof \Drupal\Core\Render\HtmlResponse || $embedded_response instanceof \Drupal\Core\Ajax\AjaxResponse'); + $this->requestStack->push($fake_request); + $event = new FilterResponseEvent($this->httpKernel, $fake_request, HttpKernelInterface::SUB_REQUEST, $embedded_response); + $this->eventDispatcher->dispatch(KernelEvents::RESPONSE, $event); + $filtered_response = $event->getResponse(); + $this->requestStack->pop(); + return $filtered_response; + } + + /** + * Sends </body> and everything after it. + * + * @param string $post_body + * The HTML response's content after the closing </body> tag. + */ + protected function sendPostBody($post_body) { + print '</body>'; + print $post_body; + flush(); + } + + /** + * Renders a placeholder, and just that placeholder. + * + * BigPipe renders placeholders independently of the rest of the content, so + * it needs to be able to render placeholders by themselves. + * + * @param string $placeholder + * The placeholder to render. + * @param array $placeholder_render_array + * The render array associated with that placeholder. + * + * @return array + * The render array representing the rendered placeholder. + * + * @see \Drupal\Core\Render\RendererInterface::renderPlaceholder() + */ + protected function renderPlaceholder($placeholder, array $placeholder_render_array) { + $elements = [ + '#markup' => $placeholder, + '#attached' => [ + 'placeholders' => [ + $placeholder => $placeholder_render_array, + ], + ], + ]; + return $this->renderer->renderPlaceholder($placeholder, $elements); + } + + /** + * Gets the BigPipe placeholder order. + * + * Determines the order in which BigPipe placeholders must be replaced. + * + * @param string $html + * HTML markup. + * + * @return array + * Indexed array; the order in which the BigPipe placeholders must be sent. + * Values are the BigPipe placeholder IDs. + */ + protected function getPlaceholderOrder($html) { + $fragments = explode('<div data-big-pipe-placeholder-id="', $html); + array_shift($fragments); + $order = []; + + foreach ($fragments as $fragment) { + $t = explode('"></div>', $fragment, 2); + $placeholder = $t[0]; + $order[] = $placeholder; + } + + return $order; + } + +} diff --git a/core/modules/big_pipe/src/Render/BigPipeInterface.php b/core/modules/big_pipe/src/Render/BigPipeInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..55a5a3d2e71963c6ef650410031a7904961fa186 --- /dev/null +++ b/core/modules/big_pipe/src/Render/BigPipeInterface.php @@ -0,0 +1,149 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe\Render\BigPipeInterface. + */ + +namespace Drupal\big_pipe\Render; + +/** + * Interface for sending an HTML response in chunks (to get faster page loads). + * + * At a high level, BigPipe sends a HTML response in chunks: + * 1. one chunk: everything until just before </body> — this contains BigPipe + * placeholders for the personalized parts of the page. Hence this sends the + * non-personalized parts of the page. Let's call it The Skeleton. + * 2. N chunks: a <script> tag per BigPipe placeholder in The Skeleton. + * 3. one chunk: </body> and everything after it. + * + * This is conceptually identical to Facebook's BigPipe (hence the name). + * + * @see https://www.facebook.com/notes/facebook-engineering/bigpipe-pipelining-web-pages-for-high-performance/389414033919 + * + * The major way in which Drupal differs from Facebook's implementation (and + * others) is in its ability to automatically figure out which parts of the page + * can benefit from BigPipe-style delivery. Drupal's render system has the + * concept of "auto-placeholdering": content that is too dynamic is replaced + * with a placeholder that can then be rendered at a later time. On top of that, + * it also has the concept of "placeholder strategies": by default, placeholders + * are replaced on the server side and the response is blocked on all of them + * being replaced. But it's possible to add additional placeholder strategies. + * BigPipe is just another placeholder strategy. Others could be ESI, AJAX … + * + * @see https://www.drupal.org/developing/api/8/render/arrays/cacheability/auto-placeholdering + * @see \Drupal\Core\Render\PlaceholderGeneratorInterface::shouldAutomaticallyPlaceholder() + * @see \Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface + * @see \Drupal\Core\Render\Placeholder\SingleFlushStrategy + * @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy + * + * There is also one noteworthy technical addition that Drupal makes. BigPipe as + * described above, and as implemented by Facebook, can only work if JavaScript + * is enabled. The BigPipe module also makes it possible to replace placeholders + * using BigPipe in-situ, without JavaScript. This is not technically BigPipe at + * all; it's just the use of multiple flushes. Since it is able to reuse much of + * the logic though, we choose to call this "no-JS BigPipe". + * + * However, there is also a tangible benefit: some dynamic/expensive content is + * not HTML, but for example a HTML attribute value (or part thereof). It's not + * possible to efficiently replace such content using JavaScript, so "classic" + * BigPipe is out of the question. For example: CSRF tokens in URLs. + * + * This allows us to use both no-JS BigPipe and "classic" BigPipe in the same + * response to maximize the amount of content we can send as early as possible. + * + * Finally, a closer look at the implementation, and how it supports and reuses + * existing Drupal concepts: + * 1. BigPipe placeholders: 1 HtmlResponse + N embedded AjaxResponses. + * - Before a BigPipe response is sent, it is just a HTML response that + * contains BigPipe placeholders. Those placeholders look like + * <div data-big-pipe-placeholder-id="…"></div>. JavaScript is used to + * replace those placeholders. + * Therefore these placeholders are actually sent to the client. + * - The Skeleton of course has attachments, including most notably asset + * libraries. And those we track in drupalSettings.ajaxPageState.libraries — + * so that when we load new content through AJAX, we don't load the same + * asset libraries again. A HTML page can have multiple AJAX responses, each + * of which should take into account the combined AJAX page state of the + * HTML document and all preceding AJAX responses. + * - BigPipe does not make use of multiple AJAX requests/responses. It uses a + * single HTML response. But it is a more long-lived one: The Skeleton is + * sent first, the closing </body> tag is not yet sent, and the connection + * is kept open. Whenever another BigPipe Placeholder is rendered, Drupal + * sends (and so actually appends to the already-sent HTML) something like + * <script type="application/json">[{"command":"settings","settings":{…}}, {"command":…}. + * - So, for every BigPipe placeholder, we send such a <script + * type="application/json"> tag. And the contents of that tag is exactly + * like an AJAX response. The BigPipe module has JavaScript that listens for + * these and applies them. Let's call it an Embedded AJAX Response (since it + * is embedded in the HTML response). Now for the interesting bit: each of + * those Embedded AJAX Responses must also take into account the cumulative + * AJAX page state of the HTML document and all preceding Embedded AJAX + * responses. + * 2. No-JS BigPipe placeholders: 1 HtmlResponse + N embedded HtmlResponses. + * - Before a BigPipe response is sent, it is just a HTML response that + * contains no-JS BigPipe placeholders. Those placeholders can take two + * different forms: + * 1. <div data-big-pipe-nojs-placeholder-id="…"></div> if it's a + * placeholder that will be replaced by HTML + * 2. big_pipe_nojs_placeholder_attribute_safe:… if it's a placeholder + * inside a HTML attribute, in which 1. would be invalid (angle brackets + * are not allowed inside HTML attributes) + * No-JS BigPipe placeholders are not replaced using JavaScript, they must + * be replaced upon sending the BigPipe response. So, while the response is + * being sent, upon encountering these placeholders, their corresponding + * placeholder replacements are sent instead. + * Therefore these placeholders are never actually sent to the client. + * - See second bullet of point 1. + * - No-JS BigPipe does not use multiple AJAX requests/responses. It uses a + * single HTML response. But it is a more long-lived one: The Skeleton is + * split into multiple parts, the separators are where the no-JS BigPipe + * placeholders used to be. Whenever another no-JS BigPipe placeholder is + * rendered, Drupal sends (and so actually appends to the already-sent HTML) + * something like + * <link rel="stylesheet" …><script …><content>. + * - So, for every no-JS BigPipe placeholder, we send its associated CSS and + * header JS that has not already been sent (the bottom JS is not yet sent, + * so we can accumulate all of it and send it together at the end). This + * ensures that the markup is rendered as it was originally intended: its + * CSS and JS used to be blocking, and it still is. Let's call it an + * Embedded HTML response. Each of those Embedded HTML Responses must also + * take into account the cumulative AJAX page state of the HTML document and + * all preceding Embedded HTML responses. + * - Finally: any non-critical JavaScript associated with all Embedded HTML + * Responses, i.e. any footer/bottom/non-header JavaScript, is loaded after + * The Skeleton. + * + * Combining all of the above, when using both BigPipe placeholders and no-JS + * BigPipe placeholders, we therefore send: 1 HtmlResponse + M Embedded HTML + * Responses + N Embedded AJAX Responses. Schematically, we send these chunks: + * 1. Byte zero until 1st no-JS placeholder: headers + <html><head /><div>…</div> + * 2. 1st no-JS placeholder replacement: <link rel="stylesheet" …><script …><content> + * 3. Content until 2nd no-JS placeholder: <div>…</div> + * 4. 2nd no-JS placeholder replacement: <link rel="stylesheet" …><script …><content> + * 5. Content until 3rd no-JS placeholder: <div>…</div> + * 6. [… repeat until all no-JS placeholder replacements are sent …] + * 7. Send content after last no-JS placeholder. + * 8. Send script_bottom (markup to load bottom i.e. non-critical JS). + * 9. 1st placeholder replacement: <script type="application/json">[{"command":"settings","settings":{…}}, {"command":…} + * 10. 2nd placeholder replacement: <script type="application/json">[{"command":"settings","settings":{…}}, {"command":…} + * 11. [… repeat until all placeholder replacements are sent …] + * 12. Send </body> and everything after it. + * 13. Terminate request/response cycle. + * + * @see \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber + * @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy + */ +interface BigPipeInterface { + + /** + * Sends an HTML response in chunks using the BigPipe technique. + * + * @param string $content + * The HTML response content to send. + * @param array $attachments + * The HTML response's attachments. + */ + public function sendContent($content, array $attachments); + +} diff --git a/core/modules/big_pipe/src/Render/BigPipeMarkup.php b/core/modules/big_pipe/src/Render/BigPipeMarkup.php new file mode 100644 index 0000000000000000000000000000000000000000..d16d9097bf09a3e9b7e34067cff46b0baea2bc0a --- /dev/null +++ b/core/modules/big_pipe/src/Render/BigPipeMarkup.php @@ -0,0 +1,28 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe\Render\BigPipeMarkup. + */ + +namespace Drupal\big_pipe\Render; + +use Drupal\Component\Render\MarkupInterface; +use Drupal\Component\Render\MarkupTrait; + +/** + * Defines an object that passes safe strings through BigPipe's render pipeline. + * + * This object should only be constructed with a known safe string. If there is + * any risk that the string contains user-entered data that has not been + * filtered first, it must not be used. + * + * @internal + * This object is marked as internal because it should only be used in the + * BigPipe render pipeline. + * + * @see \Drupal\Core\Render\Markup + */ +final class BigPipeMarkup implements MarkupInterface, \Countable { + use MarkupTrait; +} diff --git a/core/modules/big_pipe/src/Render/BigPipeResponse.php b/core/modules/big_pipe/src/Render/BigPipeResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..235a15423ceb942b842ba02568c28d1b51e97c8f --- /dev/null +++ b/core/modules/big_pipe/src/Render/BigPipeResponse.php @@ -0,0 +1,51 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe\Render\BigPipeResponse. + */ + +namespace Drupal\big_pipe\Render; + +use Drupal\Core\Render\HtmlResponse; + +/** + * A response that is sent in chunks by the BigPipe service. + * + * Note we cannot use \Symfony\Component\HttpFoundation\StreamedResponse because + * it makes the content inaccessible (hidden behind a callback), which means no + * middlewares are able to modify the content anymore. + * + * @see \Drupal\big_pipe\Render\BigPipeInterface + * + * @todo Will become obsolete with https://www.drupal.org/node/2577631 + */ +class BigPipeResponse extends HtmlResponse { + + /** + * The BigPipe service. + * + * @var \Drupal\big_pipe\Render\BigPipeInterface + */ + protected $bigPipe; + + /** + * Sets the BigPipe service to use. + * + * @param \Drupal\big_pipe\Render\BigPipeInterface $big_pipe + * The BigPipe service. + */ + public function setBigPipeService(BigPipeInterface $big_pipe) { + $this->bigPipe = $big_pipe; + } + + /** + * {@inheritdoc} + */ + public function sendContent() { + $this->bigPipe->sendContent($this->content, $this->getAttachments()); + + return $this; + } + +} diff --git a/core/modules/big_pipe/src/Render/BigPipeResponseAttachmentsProcessor.php b/core/modules/big_pipe/src/Render/BigPipeResponseAttachmentsProcessor.php new file mode 100644 index 0000000000000000000000000000000000000000..37cde3dd23fbd5664a009058beebd6ffd1fe2210 --- /dev/null +++ b/core/modules/big_pipe/src/Render/BigPipeResponseAttachmentsProcessor.php @@ -0,0 +1,113 @@ +<?php +/** + * @file + * Contains \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor. + */ + +namespace Drupal\big_pipe\Render; + +use Drupal\Core\Asset\AssetCollectionRendererInterface; +use Drupal\Core\Asset\AssetResolverInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Form\EnforcedResponseException; +use Drupal\Core\Render\AttachmentsInterface; +use Drupal\Core\Render\AttachmentsResponseProcessorInterface; +use Drupal\Core\Render\HtmlResponseAttachmentsProcessor; +use Drupal\Core\Render\RendererInterface; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Processes attachments of HTML responses with BigPipe enabled. + * + * @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor + * @see \Drupal\big_pipe\Render\BigPipeInterface + */ +class BigPipeResponseAttachmentsProcessor extends HtmlResponseAttachmentsProcessor { + + /** + * The HTML response attachments processor service. + * + * @var \Drupal\Core\Render\AttachmentsResponseProcessorInterface + */ + protected $htmlResponseAttachmentsProcessor; + + /** + * Constructs a BigPipeResponseAttachmentsProcessor object. + * + * @param \Drupal\Core\Render\AttachmentsResponseProcessorInterface $html_response_attachments_processor + * The HTML response attachments processor service. + * @param \Drupal\Core\Asset\AssetResolverInterface $asset_resolver + * An asset resolver. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * A config factory for retrieving required config objects. + * @param \Drupal\Core\Asset\AssetCollectionRendererInterface $css_collection_renderer + * The CSS asset collection renderer. + * @param \Drupal\Core\Asset\AssetCollectionRendererInterface $js_collection_renderer + * The JS asset collection renderer. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The request stack. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler service. + */ + public function __construct(AttachmentsResponseProcessorInterface $html_response_attachments_processor, AssetResolverInterface $asset_resolver, ConfigFactoryInterface $config_factory, AssetCollectionRendererInterface $css_collection_renderer, AssetCollectionRendererInterface $js_collection_renderer, RequestStack $request_stack, RendererInterface $renderer, ModuleHandlerInterface $module_handler) { + $this->htmlResponseAttachmentsProcessor = $html_response_attachments_processor; + parent::__construct($asset_resolver, $config_factory, $css_collection_renderer, $js_collection_renderer, $request_stack, $renderer, $module_handler); + } + + /** + * {@inheritdoc} + */ + public function processAttachments(AttachmentsInterface $response) { + assert('$response instanceof \Drupal\Core\Render\HtmlResponse'); + + // First, render the actual placeholders; this will cause the BigPipe + // placeholder strategy to generate BigPipe placeholders. We need those to + // exist already so that we can extract BigPipe placeholders. This is hence + // a bit of unfortunate but necessary duplication. + // @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy + // (Note this is copied verbatim from + // \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::processAttachments) + try { + $response = $this->renderPlaceholders($response); + } + catch (EnforcedResponseException $e) { + return $e->getResponse(); + } + + // Extract BigPipe placeholders; HtmlResponseAttachmentsProcessor does not + // know (nor need to know) how to process those. + $attachments = $response->getAttachments(); + $big_pipe_placeholders = []; + $big_pipe_nojs_placeholders = []; + if (isset($attachments['big_pipe_placeholders'])) { + $big_pipe_placeholders = $attachments['big_pipe_placeholders']; + unset($attachments['big_pipe_placeholders']); + } + if (isset($attachments['big_pipe_nojs_placeholders'])) { + $big_pipe_nojs_placeholders = $attachments['big_pipe_nojs_placeholders']; + unset($attachments['big_pipe_nojs_placeholders']); + } + $html_response = clone $response; + $html_response->setAttachments($attachments); + + // Call HtmlResponseAttachmentsProcessor to process all other attachments. + $processed_html_response = $this->htmlResponseAttachmentsProcessor->processAttachments($html_response); + + // Restore BigPipe placeholders. + $attachments = $processed_html_response->getAttachments(); + $big_pipe_response = clone $processed_html_response; + if (count($big_pipe_placeholders)) { + $attachments['big_pipe_placeholders'] = $big_pipe_placeholders; + } + if (count($big_pipe_nojs_placeholders)) { + $attachments['big_pipe_nojs_placeholders'] = $big_pipe_nojs_placeholders; + } + $big_pipe_response->setAttachments($attachments); + + return $big_pipe_response; + } + +} diff --git a/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php b/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php new file mode 100644 index 0000000000000000000000000000000000000000..e24c9adaaf5c2b71b4535a45e1c68e758346b7bd --- /dev/null +++ b/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php @@ -0,0 +1,273 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy + */ + +namespace Drupal\big_pipe\Render\Placeholder; + +use Drupal\Component\Utility\Html; +use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\SessionConfigurationInterface; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Defines the BigPipe placeholder strategy, to send HTML in chunks. + * + * First: the BigPipe placeholder strategy only activates if the current request + * is associated with a session. Without a session, it is assumed this response + * is not actually dynamic: if none of the placeholders show session-dependent + * information, then none of the placeholders are uncacheable or poorly + * cacheable, which means the Page Cache (for anonymous users) can deal with it. + * In other words: BigPipe works for all authenticated users and for anonymous + * users that have a session (typical example: a shopping cart). + * + * (This is the default, and other modules can subclass this placeholder + * strategy to have different rules for enabling BigPipe.) + * + * The BigPipe placeholder strategy actually consists of two substrategies, + * depending on whether the current session is in a browser with JavaScript + * enabled or not: + * 1. with JavaScript enabled: #attached[big_pipe_js_placeholders]. Their + * replacements are streamed at the end of the page: chunk 1 is the entire + * page until the closing </body> tag, chunks 2 to (N-1) are replacement + * values for the placeholders, chunk N is </body> and everything after it. + * 2. with JavaScript disabled: #attached[big_pipe_nojs_placeholders]. Their + * replacements are streamed in situ: chunk 1 is the entire page until the + * first no-JS BigPipe placeholder, chunk 2 is the replacement for that + * placeholder, chunk 3 is the chunk from after that placeholder until the + * next no-JS BigPipe placeholder, et cetera. + * + * JS BigPipe placeholders are preferred because they result in better perceived + * performance: the entire page can be sent, minus the placeholders. But it + * requires JavaScript. + * + * No-JS BigPipe placeholders result in more visible blocking: only the part of + * the page can be sent until the first placeholder, after it is rendered until + * the second, et cetera. (In essence: multiple flushes.) + * + * Finally, both of those substrategies can also be combined: some placeholders + * live in places that cannot be efficiently replaced by JavaScript, for example + * CSRF tokens in URLs. Using no-JS BigPipe placeholders in those cases allows + * the first part of the page (until the first no-JS BigPipe placeholder) to be + * sent sooner than when they would be replaced using SingleFlushStrategy, which + * would prevent anything from being sent until all those non-HTML placeholders + * would have been replaced. + * + * See \Drupal\big_pipe\Render\BigPipe for detailed documentation on how those + * different placeholders are actually replaced. + * + * @see \Drupal\big_pipe\Render\BigPipeInterface + */ +class BigPipeStrategy implements PlaceholderStrategyInterface { + + /** + * BigPipe no-JS cookie name. + */ + const NOJS_COOKIE = 'big_pipe_nojs'; + + /** + * The session configuration. + * + * @var \Drupal\Core\Session\SessionConfigurationInterface + */ + protected $sessionConfiguration; + + /** + * The request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + + /** + * The current route match. + * + * @var \Drupal\Core\Routing\RouteMatchInterface + */ + protected $routeMatch; + + /** + * Constructs a new BigPipeStrategy class. + * + * @param \Drupal\Core\Session\SessionConfigurationInterface $session_configuration + * The session configuration. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The request stack. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + */ + public function __construct(SessionConfigurationInterface $session_configuration, RequestStack $request_stack, RouteMatchInterface $route_match) { + $this->sessionConfiguration = $session_configuration; + $this->requestStack = $request_stack; + $this->routeMatch = $route_match; + } + + /** + * {@inheritdoc} + */ + public function processPlaceholders(array $placeholders) { + // Routes can opt out from using the BigPipe HTML delivery technique. + if ($this->routeMatch->getRouteObject()->getOption('_no_big_pipe')) { + return []; + } + + if (!$this->sessionConfiguration->hasSession($this->requestStack->getCurrentRequest())) { + return []; + } + + return $this->doProcessPlaceholders($placeholders); + } + + /** + * Transforms placeholders to BigPipe placeholders, either no-JS or JS. + * + * @param array $placeholders + * The placeholders to process. + * + * @return array + * The BigPipe placeholders. + */ + protected function doProcessPlaceholders(array $placeholders) { + $overridden_placeholders = []; + foreach ($placeholders as $placeholder => $placeholder_elements) { + // BigPipe uses JavaScript and the DOM to find the placeholder to replace. + // This means finding the placeholder to replace must be efficient. Most + // placeholders are HTML, which we can find efficiently thanks to the + // querySelector API. But some placeholders are HTML attribute values or + // parts thereof, and potentially even plain text in DOM text nodes. For + // BigPipe's JavaScript to find those placeholders, it would need to + // iterate over all DOM text nodes. This is highly inefficient. Therefore, + // the BigPipe placeholder strategy only converts HTML placeholders into + // BigPipe placeholders. The other placeholders need to be replaced on the + // server, not via BigPipe. + // @see \Drupal\Core\Access\RouteProcessorCsrf::renderPlaceholderCsrfToken() + // @see \Drupal\Core\Form\FormBuilder::renderFormTokenPlaceholder() + // @see \Drupal\Core\Form\FormBuilder::renderPlaceholderFormAction() + if ($placeholder[0] !== '<' || $placeholder !== Html::normalize($placeholder)) { + $overridden_placeholders[$placeholder] = static::createBigPipeNoJsPlaceholder($placeholder, $placeholder_elements, TRUE); + } + else { + // If the current request/session doesn't have JavaScript, fall back to + // no-JS BigPipe. + if ($this->requestStack->getCurrentRequest()->cookies->has(static::NOJS_COOKIE)) { + $overridden_placeholders[$placeholder] = static::createBigPipeNoJsPlaceholder($placeholder, $placeholder_elements, FALSE); + } + else { + $overridden_placeholders[$placeholder] = static::createBigPipeJsPlaceholder($placeholder, $placeholder_elements); + } + $overridden_placeholders[$placeholder]['#cache']['contexts'][] = 'cookies:' . static::NOJS_COOKIE; + } + } + + return $overridden_placeholders; + } + + /** + * Creates a BigPipe JS placeholder. + * + * @param string $original_placeholder + * The original placeholder. + * @param array $placeholder_render_array + * The render array for a placeholder. + * + * @return array + * The resulting BigPipe JS placeholder render array. + */ + protected static function createBigPipeJsPlaceholder($original_placeholder, array $placeholder_render_array) { + $big_pipe_placeholder_id = static::generateBigPipePlaceholderId($original_placeholder, $placeholder_render_array); + + return [ + '#markup' => '<div data-big-pipe-placeholder-id="' . Html::escape($big_pipe_placeholder_id) . '"></div>', + '#cache' => [ + 'max-age' => 0, + 'contexts' => [ + 'session.exists', + ], + ], + '#attached' => [ + 'library' => [ + 'big_pipe/big_pipe', + ], + // Inform BigPipe' JavaScript known BigPipe placeholder IDs (a whitelist). + 'drupalSettings' => [ + 'bigPipePlaceholderIds' => [$big_pipe_placeholder_id => TRUE], + ], + 'big_pipe_placeholders' => [ + Html::escape($big_pipe_placeholder_id) => $placeholder_render_array, + ], + ], + ]; + } + + /** + * Creates a BigPipe no-JS placeholder. + * + * @param string $original_placeholder + * The original placeholder. + * @param array $placeholder_render_array + * The render array for a placeholder. + * @param bool $placeholder_must_be_attribute_safe + * Whether the placeholder must be safe for use in a HTML attribute (in case + * it's a placeholder for a HTML attribute value or a subset of it). + * + * @return array + * The resulting BigPipe no-JS placeholder render array. + */ + protected static function createBigPipeNoJsPlaceholder($original_placeholder, array $placeholder_render_array, $placeholder_must_be_attribute_safe = FALSE) { + if (!$placeholder_must_be_attribute_safe) { + $big_pipe_placeholder = '<div data-big-pipe-nojs-placeholder-id="' . Html::escape(static::generateBigPipePlaceholderId($original_placeholder, $placeholder_render_array)) . '"></div>'; + } + else { + $big_pipe_placeholder = 'big_pipe_nojs_placeholder_attribute_safe:' . Html::escape($original_placeholder); + } + + return [ + '#markup' => $big_pipe_placeholder, + '#cache' => [ + 'max-age' => 0, + 'contexts' => [ + 'session.exists', + ], + ], + '#attached' => [ + 'big_pipe_nojs_placeholders' => [ + $big_pipe_placeholder => $placeholder_render_array, + ], + ], + ]; + } + + /** + * Generates a BigPipe placeholder ID. + * + * @param string $original_placeholder + * The original placeholder. + * @param array $placeholder_render_array + * The render array for a placeholder. + * + * @return string + * The generated BigPipe placeholder ID. + */ + protected static function generateBigPipePlaceholderId($original_placeholder, array $placeholder_render_array) { + // Generate a BigPipe placeholder ID (to be used by BigPipe's JavaScript). + // @see \Drupal\Core\Render\PlaceholderGenerator::createPlaceholder() + if (isset($placeholder_render_array['#lazy_builder'])) { + $callback = $placeholder_render_array['#lazy_builder'][0]; + $arguments = $placeholder_render_array['#lazy_builder'][1]; + $token = hash('crc32b', serialize($placeholder_render_array)); + return UrlHelper::buildQuery(['callback' => $callback, 'args' => $arguments, 'token' => $token]); + } + // When the placeholder's render array is not using a #lazy_builder, + // anything could be in there: only #lazy_builder has a strict contract that + // allows us to create a more sane selector. Therefore, simply the original + // placeholder into a usable placeholder ID, at the cost of it being obtuse. + else { + return Html::getId($original_placeholder); + } + } + +} diff --git a/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php b/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php new file mode 100644 index 0000000000000000000000000000000000000000..5514eb70fb8bbdad686ae39186f0cab591f5a4b2 --- /dev/null +++ b/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php @@ -0,0 +1,350 @@ +<?php + +/** + * @file + * Contains \Drupal\Tests\big_pipe\Unit\Render\Placeholder\BigPipePlaceholderTestCases. + */ + +namespace Drupal\big_pipe\Tests; + +use Drupal\big_pipe\Render\BigPipeMarkup; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Url; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * BigPipe placeholder test cases for use in both unit and integration tests. + * + * - Unit test: + * \Drupal\Tests\big_pipe\Unit\Render\Placeholder\BigPipeStrategyTest + * - Integration test for BigPipe with JS on: + * \Drupal\big_pipe\Tests\BigPipeTest::testBigPipe() + * - Integration test for BigPipe with JS off: + * \Drupal\big_pipe\Tests\BigPipeTest::testBigPipeNoJs() + */ +class BigPipePlaceholderTestCases { + + /** + * Gets all BigPipe placeholder test cases. + * + * @param \Symfony\Component\DependencyInjection\ContainerInterface|null $container + * Optional. Necessary to get the embedded AJAX/HTML responses. + * @param \Drupal\Core\Session\AccountInterface|null $user + * Optional. Necessary to get the embedded AJAX/HTML responses. + * + * @return \Drupal\big_pipe\Tests\BigPipePlaceholderTestCase[] + */ + public static function cases(ContainerInterface $container = NULL, AccountInterface $user = NULL) { + // Define the two types of cacheability that we expect to see. These will be + // used in the expectations. + $cacheability_depends_on_session_only = [ + 'max-age' => 0, + 'contexts' => ['session.exists'], + ]; + $cacheability_depends_on_session_and_nojs_cookie = [ + 'max-age' => 0, + 'contexts' => ['session.exists', 'cookies:big_pipe_nojs'], + ]; + + + // 1. Real-world example of HTML placeholder. + $status_messages = new BigPipePlaceholderTestCase( + [], //['#type' => 'status_messages'], + '<drupal-render-placeholder callback="Drupal\Core\Render\Element\StatusMessages::renderMessages" arguments="0" token="a8c34b5e"></drupal-render-placeholder>', + [ + '#lazy_builder' => [ + 'Drupal\Core\Render\Element\StatusMessages::renderMessages', + [NULL] + ], + ] + ); + $status_messages->bigPipePlaceholderId = 'callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e'; + $status_messages->bigPipePlaceholderRenderArray = [ + '#markup' => '<div data-big-pipe-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e"></div>', + '#cache' => $cacheability_depends_on_session_and_nojs_cookie, + '#attached' => [ + 'library' => ['big_pipe/big_pipe'], + 'drupalSettings' => [ + 'bigPipePlaceholderIds' => [ + 'callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e' => TRUE, + ], + ], + 'big_pipe_placeholders' => [ + 'callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e' => $status_messages->placeholderRenderArray, + ], + ], + ]; + $status_messages->bigPipeNoJsPlaceholder = '<div data-big-pipe-nojs-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e"></div>'; + $status_messages->bigPipeNoJsPlaceholderRenderArray = [ + '#markup' => '<div data-big-pipe-nojs-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e"></div>', + '#cache' => $cacheability_depends_on_session_and_nojs_cookie, + '#attached' => [ + 'big_pipe_nojs_placeholders' => [ + '<div data-big-pipe-nojs-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e"></div>' => $status_messages->placeholderRenderArray, + ], + ], + ]; + if ($container && $user) { + $status_messages->embeddedAjaxResponseCommands = [ + [ + 'command' => 'settings', + 'settings' => [ + 'ajaxPageState' => [ + 'theme' => 'classy', + 'libraries' => 'big_pipe/big_pipe,classy/base,classy/messages,core/drupal.active-link,core/html5shiv,core/normalize,system/base', + ], + 'pluralDelimiter' => \Drupal\Core\StringTranslation\PluralTranslatableMarkup::DELIMITER, + 'user' => [ + 'uid' => '1', + 'permissionsHash' => $container->get('user_permissions_hash_generator')->generate($user), + ], + ], + 'merge' => TRUE, + ], + [ + 'command' => 'add_css', + 'data' => '<link rel="stylesheet" href="' . base_path() . 'core/themes/classy/css/components/messages.css?' . $container->get('state')->get('system.css_js_query_string') . '" media="all" />' . "\n" + ], + [ + 'command' => 'insert', + 'method' => 'replaceWith', + 'selector' => '[data-big-pipe-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e"]', + 'data' => "\n" . ' <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . ' <h2 class="visually-hidden">Status message</h2>' . "\n" . ' Hello from BigPipe!' . "\n" . ' </div>' . "\n \n", + 'settings' => NULL, + ], + ]; + $status_messages->embeddedHtmlResponse = '<link rel="stylesheet" href="' . base_path() . 'core/themes/classy/css/components/messages.css?' . $container->get('state')->get('system.css_js_query_string') . '" media="all" />' . "\n" . "\n" . ' <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . ' <h2 class="visually-hidden">Status message</h2>' . "\n" . ' Hello from BigPipe!' . "\n" . ' </div>' . "\n \n"; + } + + + // 2. Real-world example of HTML attribute value placeholder: form action. + $form_action = new BigPipePlaceholderTestCase( + $container ? $container->get('form_builder')->getForm('Drupal\big_pipe_test\Form\BigPipeTestForm') : [], + 'form_action_cc611e1d', + [ + '#lazy_builder' => ['form_builder:renderPlaceholderFormAction', []], + ] + ); + $form_action->bigPipeNoJsPlaceholder = 'big_pipe_nojs_placeholder_attribute_safe:form_action_cc611e1d'; + $form_action->bigPipeNoJsPlaceholderRenderArray = [ + '#markup' => 'big_pipe_nojs_placeholder_attribute_safe:form_action_cc611e1d', + '#cache' => $cacheability_depends_on_session_only, + '#attached' => [ + 'big_pipe_nojs_placeholders' => [ + 'big_pipe_nojs_placeholder_attribute_safe:form_action_cc611e1d' => $form_action->placeholderRenderArray, + ], + ], + ]; + if ($container) { + $form_action->embeddedHtmlResponse = '<form class="big-pipe-test-form" data-drupal-selector="big-pipe-test-form" action="' . base_path() . 'big_pipe_test"'; + } + + + // 3. Real-world example of HTML attribute value subset placeholder: CSRF + // token in link. + $csrf_token = new BigPipePlaceholderTestCase( + [ + '#title' => 'Link with CSRF token', + '#type' => 'link', + '#url' => Url::fromRoute('system.theme_set_default'), + ], + 'e88b559cce72c80b687d56b0e2a3a5ae4b66bc0e', + [ + '#lazy_builder' => [ + 'route_processor_csrf:renderPlaceholderCsrfToken', + ['admin/config/user-interface/shortcut/manage/default/add-link-inline'] + ], + ] + ); + $csrf_token->bigPipeNoJsPlaceholder = 'big_pipe_nojs_placeholder_attribute_safe:e88b559cce72c80b687d56b0e2a3a5ae4b66bc0e'; + $csrf_token->bigPipeNoJsPlaceholderRenderArray = [ + '#markup' => 'big_pipe_nojs_placeholder_attribute_safe:e88b559cce72c80b687d56b0e2a3a5ae4b66bc0e', + '#cache' => $cacheability_depends_on_session_only, + '#attached' => [ + 'big_pipe_nojs_placeholders' => [ + 'big_pipe_nojs_placeholder_attribute_safe:e88b559cce72c80b687d56b0e2a3a5ae4b66bc0e' => $csrf_token->placeholderRenderArray, + ], + ], + ]; + if ($container) { + $csrf_token->embeddedHtmlResponse = $container->get('csrf_token')->get('admin/appearance/default'); + } + + + // 4. Edge case: custom string to be considered as a placeholder that + // happens to not be valid HTML. + $hello = new BigPipePlaceholderTestCase( + [ + '#markup' => BigPipeMarkup::create('<hello'), + '#attached' => [ + 'placeholders' => [ + '<hello' => ['#lazy_builder' => ['\Drupal\big_pipe_test\BigPipeTestController::helloOrYarhar', []]], + ] + ], + ], + '<hello', + [ + '#lazy_builder' => [ + 'hello_or_yarhar', + [] + ], + ] + ); + $hello->bigPipeNoJsPlaceholder = 'big_pipe_nojs_placeholder_attribute_safe:<hello'; + $hello->bigPipeNoJsPlaceholderRenderArray = [ + '#markup' => 'big_pipe_nojs_placeholder_attribute_safe:<hello', + '#cache' => $cacheability_depends_on_session_only, + '#attached' => [ + 'big_pipe_nojs_placeholders' => [ + 'big_pipe_nojs_placeholder_attribute_safe:<hello' => $hello->placeholderRenderArray, + ], + ], + ]; + $hello->embeddedHtmlResponse = '<marquee>Yarhar llamas forever!</marquee>'; + + + // 5. Edge case: non-#lazy_builder placeholder. + $current_time = new BigPipePlaceholderTestCase( + [ + '#markup' => BigPipeMarkup::create('<time>CURRENT TIME</time>'), + '#attached' => [ + 'placeholders' => [ + '<time>CURRENT TIME</time>' => [ + '#pre_render' => [ + '\Drupal\big_pipe_test\BigPipeTestController::currentTime', + ], + ] + ] + ] + ], + '<time>CURRENT TIME</time>', + [ + '#pre_render' => ['current_time'], + ] + ); + $current_time->bigPipePlaceholderId = 'timecurrent-timetime'; + $current_time->bigPipePlaceholderRenderArray = [ + '#markup' => '<div data-big-pipe-placeholder-id="timecurrent-timetime"></div>', + '#cache' => $cacheability_depends_on_session_and_nojs_cookie, + '#attached' => [ + 'library' => ['big_pipe/big_pipe'], + 'drupalSettings' => [ + 'bigPipePlaceholderIds' => [ + 'timecurrent-timetime' => TRUE, + ], + ], + 'big_pipe_placeholders' => [ + 'timecurrent-timetime' => $current_time->placeholderRenderArray, + ], + ], + ]; + $current_time->embeddedAjaxResponseCommands = [ + [ + 'command' => 'insert', + 'method' => 'replaceWith', + 'selector' => '[data-big-pipe-placeholder-id="timecurrent-timetime"]', + 'data' => '<time datetime=1991-03-14"></time>', + 'settings' => NULL, + ], + ]; + $current_time->bigPipeNoJsPlaceholder = '<div data-big-pipe-nojs-placeholder-id="timecurrent-timetime"></div>'; + $current_time->bigPipeNoJsPlaceholderRenderArray = [ + '#markup' => '<div data-big-pipe-nojs-placeholder-id="timecurrent-timetime"></div>', + '#cache' => $cacheability_depends_on_session_and_nojs_cookie, + '#attached' => [ + 'big_pipe_nojs_placeholders' => [ + '<div data-big-pipe-nojs-placeholder-id="timecurrent-timetime"></div>' => $current_time->placeholderRenderArray, + ], + ], + ]; + $current_time->embeddedHtmlResponse = '<time datetime=1991-03-14"></time>'; + + + return [ + 'html' => $status_messages, + 'html_attribute_value' => $form_action, + 'html_attribute_value_subset' => $csrf_token, + 'edge_case__invalid_html' => $hello, + 'edge_case__html_non_lazy_builder' => $current_time, + ]; + } + +} + +class BigPipePlaceholderTestCase { + + /** + * The original render array. + * + * @var array + */ + public $renderArray; + + /** + * The expected corresponding placeholder string. + * + * @var string + */ + public $placeholder; + + /** + * The expected corresponding placeholder render array. + * + * @var array + */ + public $placeholderRenderArray; + + /** + * The expected BigPipe placeholder ID. + * + * (Only possible for HTML placeholders.) + * + * @var null|string + */ + public $bigPipePlaceholderId = NULL; + + /** + * The corresponding expected BigPipe placeholder render array. + * + * @var null|array + */ + public $bigPipePlaceholderRenderArray = NULL; + + /** + * The corresponding expected embedded AJAX response. + * + * @var null|array + */ + public $embeddedAjaxResponseCommands = NULL; + + + /** + * The expected BigPipe no-JS placeholder. + * + * (Possible for all placeholders, HTML or non-HTML.) + * + * @var string + */ + public $bigPipeNoJsPlaceholder; + + /** + * The corresponding expected BigPipe no-JS placeholder render array. + * + * @var array + */ + public $bigPipeNoJsPlaceholderRenderArray; + + /** + * The corresponding expected embedded HTML response. + * + * @var string + */ + public $embeddedHtmlResponse; + + public function __construct(array $render_array, $placeholder, array $placeholder_render_array) { + $this->renderArray = $render_array; + $this->placeholder = $placeholder; + $this->placeholderRenderArray = $placeholder_render_array; + } + +} diff --git a/core/modules/big_pipe/src/Tests/BigPipeTest.php b/core/modules/big_pipe/src/Tests/BigPipeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b30d27b8807b208ea58f73e5f476d7d69c1ef980 --- /dev/null +++ b/core/modules/big_pipe/src/Tests/BigPipeTest.php @@ -0,0 +1,356 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe\Tests\BigPipeTest. + */ + +namespace Drupal\big_pipe\Tests; + +use Drupal\Component\Serialization\Json; +use Drupal\Component\Utility\Html; +use Drupal\Core\Url; +use Drupal\simpletest\WebTestBase; + +/** + * Tests BigPipe's no-JS detection & response delivery (with and without JS). + * + * Covers: + * - big_pipe_page_attachments() + * - \Drupal\big_pipe\Controller\BigPipeController + * - \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber + * - \Drupal\big_pipe\Render\BigPipe + * + * @group big_pipe + */ +class BigPipeTest extends WebTestBase { + + const START_SIGNAL= '<script type="application/json" data-big-pipe-event="start"></script>'; + const STOP_SIGNAL= '<script type="application/json" data-big-pipe-event="stop"></script>'; + + /** + * Modules to enable. + * + * @var array + */ + public static $modules = ['big_pipe', 'big_pipe_test']; + + /** + * {@inheritdoc} + */ + protected $dumpHeaders = TRUE; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + // Ignore the <meta> refresh that big_pipe.module sets. It causes a redirect + // to a page that sets another cookie, which causes WebTestBase to lose the + // session cookie. To avoid this problem, tests should first call + // drupalGet() and then call checkForMetaRefresh() manually, and then reset + // $this->maximumMetaRefreshCount and $this->metaRefreshCount. + // @see doMetaRefresh() + $this->maximumMetaRefreshCount = 0; + } + + /** + * Performs a single <meta> refresh explicitly. + * + * This test disables the automatic <meta> refresh checking, each time it is + * desired that this runs, a test case must explicitly call this. + * + * @see setUp() + */ + protected function performMetaRefresh() { + $this->maximumMetaRefreshCount = 1; + $this->checkForMetaRefresh(); + $this->maximumMetaRefreshCount = 0; + $this->metaRefreshCount = 0; + } + + /** + * Tests BigPipe's no-JS detection. + * + * Covers: + * - big_pipe_page_attachments() + * - \Drupal\big_pipe\Controller\BigPipeController + */ + public function testNoJsDetection() { + // 1. No session (anonymous). + $this->drupalGet(Url::fromRoute('<front>')); + $this->assertSessionCookieExists(FALSE); + $this->assertBigPipeNoJsCookieExists(FALSE); + $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL='); + + // 2. Session (authenticated). + $this->drupalLogin($this->rootUser); + $this->assertSessionCookieExists(TRUE); + $this->assertBigPipeNoJsCookieExists(FALSE); + $this->assertRaw('<noscript><meta http-equiv="Refresh" content="0; URL=' . base_path() . 'big_pipe/no-js?destination=' . base_path() . 'user/1" />' . "\n" . '</noscript>'); + $this->assertBigPipeNoJsMetaRefreshRedirect(); + $this->assertBigPipeNoJsCookieExists(TRUE); + $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL='); + $this->drupalLogout(); + + // Close the prior connection and remove the collected state. + $this->curlClose(); + $this->curlCookies = []; + $this->cookies = []; + + // 3. Session (anonymous). + $this->drupalGet(Url::fromRoute('user.login', [], ['query' => ['trigger_session' => 1]])); + $this->drupalGet(Url::fromRoute('user.login')); + $this->assertSessionCookieExists(TRUE); + $this->assertBigPipeNoJsCookieExists(FALSE); + $this->assertRaw('<noscript><meta http-equiv="Refresh" content="0; URL=' . base_path() . 'big_pipe/no-js?destination=' . base_path() . 'user/login" />' . "\n" . '</noscript>'); + $this->assertBigPipeNoJsMetaRefreshRedirect(); + $this->assertBigPipeNoJsCookieExists(TRUE); + $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL='); + + // Close the prior connection and remove the collected state. + $this->curlClose(); + $this->curlCookies = []; + $this->cookies = []; + + // Edge case: route with '_no_big_pipe' option. + $this->drupalGet(Url::fromRoute('big_pipe_test.no_big_pipe')); + $this->assertSessionCookieExists(FALSE); + $this->assertBigPipeNoJsCookieExists(FALSE); + $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL='); + $this->drupalLogin($this->rootUser); + $this->drupalGet(Url::fromRoute('big_pipe_test.no_big_pipe')); + $this->assertSessionCookieExists(TRUE); + $this->assertBigPipeNoJsCookieExists(FALSE); + $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL='); + } + + /** + * Tests BigPipe-delivered HTML responses when JavaScript is enabled. + * + * Covers: + * - \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber + * - \Drupal\big_pipe\Render\BigPipe + * - \Drupal\big_pipe\Render\BigPipe::sendPlaceholders() + * + * @see \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases + */ + public function testBigPipe() { + $this->drupalLogin($this->rootUser); + $this->assertSessionCookieExists(TRUE); + $this->assertBigPipeNoJsCookieExists(FALSE); + + // By not calling performMetaRefresh() here, we simulate JavaScript being + // enabled, because as far as the BigPipe module is concerned, JavaScript is + // enabled in the browser as long as the BigPipe no-JS cookie is *not* set. + // @see setUp() + // @see performMetaRefresh() + + $this->drupalGet(Url::fromRoute('big_pipe_test')); + $this->assertBigPipeResponseHeadersPresent(); + + $cases = $this->getTestCases(); + $this->assertBigPipeNoJsPlaceholders([ + $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholder => $cases['edge_case__invalid_html']->embeddedHtmlResponse, + $cases['html_attribute_value']->bigPipeNoJsPlaceholder => $cases['html_attribute_value']->embeddedHtmlResponse, + $cases['html_attribute_value_subset']->bigPipeNoJsPlaceholder => $cases['html_attribute_value_subset']->embeddedHtmlResponse, + ]); + $this->assertBigPipePlaceholders([ + $cases['html']->bigPipePlaceholderId => Json::encode($cases['html']->embeddedAjaxResponseCommands), + $cases['edge_case__html_non_lazy_builder']->bigPipePlaceholderId => Json::encode($cases['edge_case__html_non_lazy_builder']->embeddedAjaxResponseCommands), + ]); + + $this->pass('Verifying BigPipe assets are present…', 'Debug'); + $this->assertFalse(empty($this->getDrupalSettings()), 'drupalSettings present.'); + $this->assertTrue(in_array('big_pipe/big_pipe', explode(',', $this->getDrupalSettings()['ajaxPageState']['libraries'])), 'BigPipe asset library is present.'); + } + + /** + * Tests BigPipe-delivered HTML responses when JavaScript is disabled. + * + * Covers: + * - \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber + * - \Drupal\big_pipe\Render\BigPipe + * - \Drupal\big_pipe\Render\BigPipe::sendNoJsPlaceholders() + * + * @see \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases + */ + public function testBigPipeNoJs() { + $this->drupalLogin($this->rootUser); + $this->assertSessionCookieExists(TRUE); + $this->assertBigPipeNoJsCookieExists(FALSE); + + // By calling performMetaRefresh() here, we simulate JavaScript being + // disabled, because as far as the BigPipe module is concerned, it is + // enabled in the browser when the BigPipe no-JS cookie is set. + // @see setUp() + // @see performMetaRefresh() + $this->performMetaRefresh(); + $this->assertBigPipeNoJsCookieExists(TRUE); + + $this->drupalGet(Url::fromRoute('big_pipe_test')); + $this->assertBigPipeResponseHeadersPresent(); + + $cases = $this->getTestCases(); + $this->assertBigPipeNoJsPlaceholders([ + $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholder => $cases['edge_case__invalid_html']->embeddedHtmlResponse, + $cases['html_attribute_value']->bigPipeNoJsPlaceholder => $cases['html_attribute_value']->embeddedHtmlResponse, + $cases['html_attribute_value_subset']->bigPipeNoJsPlaceholder => $cases['html_attribute_value_subset']->embeddedHtmlResponse, + $cases['html']->bigPipeNoJsPlaceholder => $cases['html']->embeddedHtmlResponse, + $cases['edge_case__html_non_lazy_builder']->bigPipeNoJsPlaceholder => $cases['edge_case__html_non_lazy_builder']->embeddedHtmlResponse, + ]); + + $this->pass('Verifying there are no BigPipe placeholders & replacements…', 'Debug'); + $this->assertEqual('<none>', $this->drupalGetHeader('BigPipe-Test-Placeholders')); + $this->pass('Verifying BigPipe start/stop signals are absent…', 'Debug'); + $this->assertNoRaw(static::START_SIGNAL, 'BigPipe start signal absent.'); + $this->assertNoRaw(static::STOP_SIGNAL, 'BigPipe stop signal absent.'); + + $this->pass('Verifying BigPipe assets are absent…', 'Debug'); + $this->assertFalse(empty($this->getDrupalSettings()), 'drupalSettings and BigPipe asset library absent.'); + } + + protected function assertBigPipeResponseHeadersPresent() { + $this->pass('Verifying BigPipe response headers…', 'Debug'); + $this->assertTrue(FALSE !== strpos($this->drupalGetHeader('Cache-Control'), 'private'), 'Cache-Control header set to "private".'); + $this->assertEqual('no-store, content="BigPipe/1.0"', $this->drupalGetHeader('Surrogate-Control')); + $this->assertEqual('no', $this->drupalGetHeader('X-Accel-Buffering')); + } + + /** + * Asserts expected BigPipe no-JS placeholders are present and replaced. + * + * @param array $expected_big_pipe_nojs_placeholders + * Keys: BigPipe no-JS placeholder markup. Values: expected replacement + * markup. + */ + protected function assertBigPipeNoJsPlaceholders(array $expected_big_pipe_nojs_placeholders) { + $this->pass('Verifying BigPipe no-JS placeholders & replacements…', 'Debug'); + $this->assertSetsEqual(array_keys($expected_big_pipe_nojs_placeholders), array_map('rawurldecode', explode(' ', $this->drupalGetHeader('BigPipe-Test-No-Js-Placeholders')))); + foreach ($expected_big_pipe_nojs_placeholders as $big_pipe_nojs_placeholder => $expected_replacement) { + $this->pass('Checking whether the replacement for the BigPipe no-JS placeholder "' . $big_pipe_nojs_placeholder . '" is present:'); + $this->assertRaw($expected_replacement); + } + } + + /** + * Asserts expected BigPipe placeholders are present and replaced. + * + * @param array $expected_big_pipe_placeholders + * Keys: BigPipe placeholder IDs. Values: expected AJAX response. + */ + protected function assertBigPipePlaceholders(array $expected_big_pipe_placeholders) { + $this->pass('Verifying BigPipe placeholders & replacements…', 'Debug'); + $this->assertSetsEqual(array_keys($expected_big_pipe_placeholders), explode(' ', $this->drupalGetHeader('BigPipe-Test-Placeholders'))); + $placeholder_positions = []; + $placeholder_replacement_positions = []; + foreach ($expected_big_pipe_placeholders as $big_pipe_placeholder_id => $expected_ajax_response) { + $this->pass('BigPipe placeholder: ' . $big_pipe_placeholder_id, 'Debug'); + // Verify expected placeholder. + $expected_placeholder_html = '<div data-big-pipe-placeholder-id="' . $big_pipe_placeholder_id . '"></div>'; + $this->assertRaw($expected_placeholder_html, 'BigPipe placeholder for placeholder ID "' . $big_pipe_placeholder_id . '" found.'); + $pos = strpos($this->getRawContent(), $expected_placeholder_html); + $placeholder_positions[$pos] = $big_pipe_placeholder_id; + // Verify expected placeholder replacement. + $result = $this->xpath('//script[@data-big-pipe-replacement-for-placeholder-with-id=:id]', [':id' => Html::decodeEntities($big_pipe_placeholder_id)]); + $this->assertEqual($expected_ajax_response, trim((string) $result[0])); + $expected_placeholder_replacement = '<script type="application/json" data-big-pipe-replacement-for-placeholder-with-id="' . $big_pipe_placeholder_id . '">'; + $this->assertRaw($expected_placeholder_replacement); + $pos = strpos($this->getRawContent(), $expected_placeholder_replacement); + $placeholder_replacement_positions[$pos] = $big_pipe_placeholder_id; + } + ksort($placeholder_positions, SORT_NUMERIC); + $this->assertEqual(array_keys($expected_big_pipe_placeholders), array_values($placeholder_positions)); + $this->assertEqual(count($expected_big_pipe_placeholders), preg_match_all('/' . preg_quote('<div data-big-pipe-placeholder-id="', '/') . '/', $this->getRawContent())); + $this->assertEqual(array_keys($expected_big_pipe_placeholders), array_values($placeholder_replacement_positions)); + $this->assertEqual(count($expected_big_pipe_placeholders), preg_match_all('/' . preg_quote('<script type="application/json" data-big-pipe-replacement-for-placeholder-with-id="', '/') . '/', $this->getRawContent())); + + $this->pass('Verifying BigPipe start/stop signals…', 'Debug'); + $this->assertRaw(static::START_SIGNAL, 'BigPipe start signal present.'); + $this->assertRaw(static::STOP_SIGNAL, 'BigPipe stop signal present.'); + $start_signal_position = strpos($this->getRawContent(), static::START_SIGNAL); + $stop_signal_position = strpos($this->getRawContent(), static::STOP_SIGNAL); + $this->assertTrue($start_signal_position < $stop_signal_position, 'BigPipe start signal appears before stop signal.'); + + $this->pass('Verifying BigPipe placeholder replacements and start/stop signals were streamed in the correct order…', 'Debug'); + $expected_stream_order = array_keys($expected_big_pipe_placeholders); + array_unshift($expected_stream_order, static::START_SIGNAL); + array_push($expected_stream_order, static::STOP_SIGNAL); + $actual_stream_order = $placeholder_replacement_positions + [ + $start_signal_position => static::START_SIGNAL, + $stop_signal_position => static::STOP_SIGNAL, + ]; + ksort($actual_stream_order, SORT_NUMERIC); + $this->assertEqual($expected_stream_order, array_values($actual_stream_order)); + } + + /** + * @return \Drupal\big_pipe\Tests\BigPipePlaceholderTestCase[] + */ + protected function getTestCases() { + // Ensure we can generate CSRF tokens for the current user's session. + $session_data = $this->container->get('session_handler.write_safe')->read($this->cookies[$this->getSessionName()]['value']); + $csrf_token_seed = unserialize(explode('_sf2_meta|', $session_data)[1])['s']; + $this->container->get('session_manager.metadata_bag')->setCsrfTokenSeed($csrf_token_seed); + + return \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases::cases($this->container, $this->rootUser); + } + + /** + * Asserts whether arrays A and B are equal, when treated as sets. + */ + protected function assertSetsEqual(array $a, array $b) { + return count($a) == count($b) && !array_diff_assoc($a, $b); + } + + /** + * Asserts whether a BigPipe no-JS cookie exists or not. + */ + protected function assertBigPipeNoJsCookieExists($expected) { + $this->assertCookieExists('big_pipe_nojs', $expected, 'BigPipe no-JS'); + } + + /** + * Asserts whether a session cookie exists or not. + */ + protected function assertSessionCookieExists($expected) { + $this->assertCookieExists($this->getSessionName(), $expected, 'Session'); + } + + /** + * Asserts whether a cookie exists on the client or not. + */ + protected function assertCookieExists($cookie_name, $expected, $cookie_label) { + $non_deleted_cookies = array_filter($this->cookies, function ($item) { return $item['value'] !== 'deleted'; }); + $this->assertEqual($expected, isset($non_deleted_cookies[$cookie_name]), $expected ? "$cookie_label cookie exists." : "$cookie_label cookie does not exist."); + } + + /** + * Calls ::performMetaRefresh() and asserts the responses. + */ + protected function assertBigPipeNoJsMetaRefreshRedirect() { + $original_url = $this->url; + $this->performMetaRefresh(); + + $this->assertEqual($original_url, $this->url, 'Redirected back to the original location.'); + + $headers = $this->drupalGetHeaders(TRUE); + $this->assertEqual(2, count($headers), 'Two requests were made upon following the <meta> refresh, there are headers for two responses.'); + + // First response: redirect. + $this->assertEqual('HTTP/1.1 302 Found', $headers[0][':status'], 'The first response was a 302 (redirect).'); + $this->assertIdentical(0, strpos($headers[0]['set-cookie'], 'big_pipe_nojs=1'), 'The first response sets the big_pipe_nojs cookie.'); + $this->assertEqual($original_url, $headers[0]['location'], 'The first response redirected back to the original page.'); + $this->assertTrue(empty(array_diff(['cookies:big_pipe_nojs', 'session.exists'], explode(' ', $headers[0]['x-drupal-cache-contexts']))), 'The first response varies by the "cookies:big_pipe_nojs" and "session.exists" cache contexts.'); + $this->assertFalse(isset($headers[0]['surrogate-control']), 'The first response has no "Surrogate-Control" header.'); + + // Second response: redirect followed. + $this->assertEqual('HTTP/1.1 200 OK', $headers[1][':status'], 'The second response was a 200.'); + $this->assertTrue(empty(array_diff(['cookies:big_pipe_nojs', 'session.exists'], explode(' ', $headers[0]['x-drupal-cache-contexts']))), 'The first response varies by the "cookies:big_pipe_nojs" and "session.exists" cache contexts.'); + $this->assertEqual('no-store, content="BigPipe/1.0"', $headers[1]['surrogate-control'], 'The second response has a "Surrogate-Control" header.'); + + $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL=', 'Once the BigPipe no-JS cookie is set, the <meta> refresh is absent: only one redirect ever happens.'); + } + +} diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.info.yml b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..46ed28d7bb63722bf6555a63d041ae01706d3967 --- /dev/null +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.info.yml @@ -0,0 +1,6 @@ +name: 'BigPipe test' +type: module +description: 'Support module for BigPipe testing.' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.module b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.module new file mode 100644 index 0000000000000000000000000000000000000000..c808ce90e9c46b387f3ba8e30293e72fb2f18640 --- /dev/null +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.module @@ -0,0 +1,13 @@ +<?php + +/** + * Implements hook_page_top(). + */ +function big_pipe_test_page_top(array &$page_top) { + // Ensure this hook is invoked on every page load. + $page_top['#cache']['max-age'] = 0; + + if (\Drupal::request()->query->get('trigger_session')) { + $_SESSION['big_pipe_test'] = TRUE; + } +} diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..e573913bbcb1a5bd660502cae83c99c29b3162c4 --- /dev/null +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml @@ -0,0 +1,17 @@ +big_pipe_test: + path: '/big_pipe_test' + defaults: + _controller: '\Drupal\big_pipe_test\BigPipeTestController::test' + _title: 'BigPipe test' + requirements: + _access: 'TRUE' + +big_pipe_test.no_big_pipe: + path: '/no_big_pipe' + defaults: + _controller: '\Drupal\big_pipe_test\BigPipeTestController::test' + _title: 'BigPipe test' + options: + _no_big_pipe: TRUE + requirements: + _access: 'TRUE' diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.services.yml b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..5ec4895114452e2ab224c9aab917d0e3537f7791 --- /dev/null +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.services.yml @@ -0,0 +1,5 @@ +services: + big_pipe_test_subscriber: + class: Drupal\big_pipe_test\EventSubscriber\BigPipeTestSubscriber + tags: + - { name: event_subscriber } diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php new file mode 100644 index 0000000000000000000000000000000000000000..3a663fcf23d39cf47a8d1bb9ec155bc8e888f3da --- /dev/null +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php @@ -0,0 +1,66 @@ +<?php + +namespace Drupal\big_pipe_test; + +use Drupal\big_pipe\Render\BigPipeMarkup; + +class BigPipeTestController { + + /** + * Returns a all BigPipe placeholder test case render arrays. + * + * @return array + */ + public function test() { + $build = []; + + $cases = \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases::cases(\Drupal::getContainer()); + + // 1. HTML placeholder: status messages. Drupal renders those automatically, + // so all that we need to do in this controller is set a message. + drupal_set_message('Hello from BigPipe!'); + $build['html'] = $cases['html']->renderArray; + + // 2. HTML attribute value placeholder: form action. + $build['html_attribute_value'] = $cases['html_attribute_value']->renderArray; + + // 3. HTML attribute value subset placeholder: CSRF token in link. + $build['html_attribute_value_subset'] = $cases['html_attribute_value_subset']->renderArray; + + // 4. Edge case: custom string to be considered as a placeholder that + // happens to not be valid HTML. + $build['edge_case__invalid_html'] = $cases['edge_case__invalid_html']->renderArray; + + // 5. Edge case: non-#lazy_builder placeholder. + $build['edge_case__html_non_lazy_builder'] = $cases['edge_case__html_non_lazy_builder']->renderArray; + + return $build; + } + + /** + * #lazy_builder callback; builds <time> markup with current time. + * + * Note: does not actually use current time, that would complicate testing. + * + * @return array + */ + public static function currentTime() { + return [ + '#markup' => '<time datetime=' . date('Y-m-d', 668948400) . '"></time>', + '#cache' => ['max-age' => 0] + ]; + } + + /** + * #lazy_builder callback; says "hello" or "yarhar". + * + * @return array + */ + public static function helloOrYarhar() { + return [ + '#markup' => BigPipeMarkup::create('<marquee>Yarhar llamas forever!</marquee>'), + '#cache' => ['max-age' => 0], + ]; + } + +} diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/EventSubscriber/BigPipeTestSubscriber.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/EventSubscriber/BigPipeTestSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..6af1028e495bc037cdf7aca05a94b41e45b62344 --- /dev/null +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/EventSubscriber/BigPipeTestSubscriber.php @@ -0,0 +1,53 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe_test\EventSubscriber\BigPipeTestSubscriber. + */ + +namespace Drupal\big_pipe_test\EventSubscriber; + +use Drupal\Core\Render\HtmlResponse; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +class BigPipeTestSubscriber implements EventSubscriberInterface { + + /** + * Exposes all BigPipe placeholders (JS and no-JS) via headers for testing. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The event to process. + */ + public function onRespond(FilterResponseEvent $event) { + $response = $event->getResponse(); + if (!$response instanceof HtmlResponse) { + return; + } + + $attachments = $response->getAttachments(); + + $response->headers->set('BigPipe-Test-Placeholders', '<none>'); + $response->headers->set('BigPipe-Test-No-Js-Placeholders', '<none>'); + + if (!empty($attachments['big_pipe_placeholders'])) { + $response->headers->set('BigPipe-Test-Placeholders', implode(' ', array_keys($attachments['big_pipe_placeholders']))); + } + + if (!empty($attachments['big_pipe_nojs_placeholders'])) { + $response->headers->set('BigPipe-Test-No-Js-Placeholders', implode(' ', array_map('rawurlencode', array_keys($attachments['big_pipe_nojs_placeholders'])))); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Run *just* before \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber::onRespond(). + $events[KernelEvents::RESPONSE][] = ['onRespond', -99999]; + + return $events; + } + +} diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/Form/BigPipeTestForm.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/Form/BigPipeTestForm.php new file mode 100644 index 0000000000000000000000000000000000000000..21b6462d0b0a7229266234a6008fbc053f123267 --- /dev/null +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/Form/BigPipeTestForm.php @@ -0,0 +1,45 @@ +<?php + +/** + * @file + * Contains \Drupal\big_pipe_test\Form\BigPipeTestForm. + */ + +namespace Drupal\big_pipe_test\Form; + +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; + +class BigPipeTestForm extends FormBase { + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'big_pipe_test_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $form['#token'] = FALSE; + + $form['big_pipe'] = array( + '#type' => 'checkboxes', + '#title' => $this->t('BigPipe works…'), + '#options' => [ + 'js' => $this->t('… with JavaScript'), + 'nojs' => $this->t('… without JavaScript'), + ], + ); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { } + +} diff --git a/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php b/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b45129c432792ab50cefc59e1ca162a4e7f8092e --- /dev/null +++ b/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php @@ -0,0 +1,148 @@ +<?php + +/** + * @file + * Contains \Drupal\Tests\big_pipe\Unit\Render\BigPipeResponseAttachmentsProcessorTest. + */ + +namespace Drupal\Tests\big_pipe\Unit\Render; + +use Drupal\big_pipe\Render\BigPipeResponse; +use Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor; +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Asset\AssetCollectionRendererInterface; +use Drupal\Core\Asset\AssetResolverInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Render\AttachmentsInterface; +use Drupal\Core\Render\AttachmentsResponseProcessorInterface; +use Drupal\Core\Render\HtmlResponse; +use Drupal\Core\Render\RendererInterface; +use Drupal\Tests\UnitTestCase; +use Prophecy\Argument; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @coversDefaultClass \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor + * @group big_pipe + */ +class BigPipeResponseAttachmentsProcessorTest extends UnitTestCase { + + /** + * @covers ::processAttachments + * + * @dataProvider nonHtmlResponseProvider + * + * @expectedException \AssertionError + */ + public function testNonHtmlResponse($response_class) { + $big_pipe_response_attachments_processor = $this->createBigPipeResponseAttachmentsProcessor($this->prophesize(AttachmentsResponseProcessorInterface::class)); + + $non_html_response = new $response_class(); + $big_pipe_response_attachments_processor->processAttachments($non_html_response); + } + + function nonHtmlResponseProvider() { + return [ + 'AjaxResponse, which implements AttachmentsInterface' => [AjaxResponse::class], + 'A dummy that implements AttachmentsInterface' => [get_class($this->prophesize(AttachmentsInterface::class)->reveal())], + ]; + } + + /** + * @covers ::processAttachments + * + * @dataProvider attachmentsProvider + */ + public function testHtmlResponse(array $attachments) { + $big_pipe_response = new BigPipeResponse('original'); + $big_pipe_response->setAttachments($attachments); + + // This mock is the main expectation of this test: verify that the decorated + // service (that is this mock) never receives BigPipe placeholder + // attachments, because it doesn't know (nor should it) how to handle them. + $html_response_attachments_processor = $this->prophesize(AttachmentsResponseProcessorInterface::class); + $html_response_attachments_processor->processAttachments(Argument::that(function ($response) { + return $response instanceof HtmlResponse && empty(array_intersect(['big_pipe_placeholders', 'big_pipe_nojs_placeholders'], array_keys($response->getAttachments()))); + })) + ->will(function ($args) { + /** @var \Symfony\Component\HttpFoundation\Response|\Drupal\Core\Render\AttachmentsInterface $response */ + $response = $args[0]; + // Simulate its actual behavior. + $attachments = array_diff_key($response->getAttachments(), ['html_response_attachment_placeholders' => TRUE]); + $response->setContent('processed'); + $response->setAttachments($attachments); + return $response; + }) + ->shouldBeCalled(); + + $big_pipe_response_attachments_processor = $this->createBigPipeResponseAttachmentsProcessor($html_response_attachments_processor); + $processed_big_pipe_response = $big_pipe_response_attachments_processor->processAttachments($big_pipe_response); + + // The secondary expectation of this test: the original (passed in) response + // object remains unchanged, the processed (returned) response object has + // the expected values. + $this->assertSame($attachments, $big_pipe_response->getAttachments(), 'Attachments of original response object MUST NOT be changed.'); + $this->assertEquals('original', $big_pipe_response->getContent(), 'Content of original response object MUST NOT be changed.'); + $this->assertEquals(array_diff_key($attachments, ['html_response_attachment_placeholders' => TRUE]), $processed_big_pipe_response->getAttachments(), 'Attachments of returned (processed) response object MUST be changed.'); + $this->assertEquals('processed', $processed_big_pipe_response->getContent(), 'Content of returned (processed) response object MUST be changed.'); + } + + public function attachmentsProvider() { + $typical_cases = [ + 'no attachments' => [[]], + 'libraries' => [['library' => ['core/drupal']]], + 'libraries + drupalSettings' => [['library' => ['core/drupal'], 'drupalSettings' => ['foo' => 'bar']]], + ]; + + $official_attachment_types = ['html_head', 'feed', 'html_head_link', 'http_header', 'library', 'placeholders', 'drupalSettings', 'html_response_attachment_placeholders']; + $official_attachments_with_random_values = []; + foreach ($official_attachment_types as $type) { + $official_attachments_with_random_values[$type] = $this->randomMachineName(); + } + $random_attachments = ['random' . $this->randomMachineName() => $this->randomMachineName()]; + $edge_cases = [ + 'all official attachment types, with random assigned values, even if technically not valid, to prove BigPipeResponseAttachmentsProcessor is a perfect decorator' => [$official_attachments_with_random_values], + 'random attachment type (unofficial), with random assigned value, to prove BigPipeResponseAttachmentsProcessor is a perfect decorator' => [$random_attachments], + ]; + + $big_pipe_placeholder_attachments = ['big_pipe_placeholders' => $this->randomMachineName()]; + $big_pipe_nojs_placeholder_attachments = ['big_pipe_nojs_placeholders' => $this->randomMachineName()]; + $big_pipe_cases = [ + 'only big_pipe_placeholders' => [$big_pipe_placeholder_attachments], + 'only big_pipe_nojs_placeholders' => [$big_pipe_nojs_placeholder_attachments], + 'big_pipe_placeholders + big_pipe_nojs_placeholders' => [$big_pipe_placeholder_attachments + $big_pipe_nojs_placeholder_attachments], + ]; + + $combined_cases = [ + 'all official attachment types + big_pipe_placeholders + big_pipe_nojs_placeholders' => [$official_attachments_with_random_values + $big_pipe_placeholder_attachments + $big_pipe_nojs_placeholder_attachments], + 'random attachment types + big_pipe_placeholders + big_pipe_nojs_placeholders' => [$random_attachments + $big_pipe_placeholder_attachments + $big_pipe_nojs_placeholder_attachments], + ]; + + return $typical_cases + $edge_cases + $big_pipe_cases + $combined_cases; + } + + /** + * Creates a BigPipeResponseAttachmentsProcessor with mostly dummies. + * + * @param \Prophecy\Prophecy\ObjectProphecy $decorated_html_response_attachments_processor + * An object prophecy implementing AttachmentsResponseProcessorInterface. + * + * @return \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor + * The BigPipeResponseAttachmentsProcessor to test. + */ + protected function createBigPipeResponseAttachmentsProcessor(ObjectProphecy $decorated_html_response_attachments_processor) { + return new BigPipeResponseAttachmentsProcessor( + $decorated_html_response_attachments_processor->reveal(), + $this->prophesize(AssetResolverInterface::class)->reveal(), + $this->prophesize(ConfigFactoryInterface::class)->reveal(), + $this->prophesize(AssetCollectionRendererInterface::class)->reveal(), + $this->prophesize(AssetCollectionRendererInterface::class)->reveal(), + $this->prophesize(RequestStack::class)->reveal(), + $this->prophesize(RendererInterface::class)->reveal(), + $this->prophesize(ModuleHandlerInterface::class)->reveal() + ); + } + +} diff --git a/core/modules/big_pipe/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php b/core/modules/big_pipe/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d726a8378a444a287b528ba7cf0eb3789282b591 --- /dev/null +++ b/core/modules/big_pipe/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php @@ -0,0 +1,104 @@ +<?php + +/** + * @file + * Contains \Drupal\Tests\big_pipe\Unit\Render\Placeholder\BigPipeStrategyTest. + */ + +namespace Drupal\Tests\big_pipe\Unit\Render\Placeholder; + +use Drupal\big_pipe\Render\Placeholder\BigPipeStrategy; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\SessionConfigurationInterface; +use Drupal\Tests\UnitTestCase; +use Prophecy\Argument; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Routing\Route; + +/** + * @coversDefaultClass \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy + * @group big_pipe + */ +class BigPipeStrategyTest extends UnitTestCase { + + /** + * @covers ::processPlaceholders + * + * @dataProvider placeholdersProvider + */ + public function testProcessPlaceholders(array $placeholders, $route_match_has_no_big_pipe_option, $request_has_session, $request_has_big_pipe_nojs_cookie, array $expected_big_pipe_placeholders) { + $request = new Request(); + if ($request_has_big_pipe_nojs_cookie) { + $request->cookies->set(BigPipeStrategy::NOJS_COOKIE, 1); + } + $request_stack = $this->prophesize(RequestStack::class); + $request_stack->getCurrentRequest() + ->willReturn($request); + + $session_configuration = $this->prophesize(SessionConfigurationInterface::class); + $session_configuration->hasSession(Argument::type(Request::class)) + ->willReturn($request_has_session); + + $route = $this->prophesize(Route::class); + $route->getOption('_no_big_pipe') + ->willReturn($route_match_has_no_big_pipe_option); + $route_match = $this->prophesize(RouteMatchInterface::class); + $route_match->getRouteObject() + ->willReturn($route); + + $big_pipe_strategy = new BigPipeStrategy($session_configuration->reveal(), $request_stack->reveal(), $route_match->reveal()); + $processed_placeholders = $big_pipe_strategy->processPlaceholders($placeholders); + + if (!$route_match_has_no_big_pipe_option && $request_has_session) { + $this->assertSameSize($expected_big_pipe_placeholders, $processed_placeholders, 'BigPipe is able to deliver all placeholders.'); + foreach (array_keys($placeholders) as $placeholder) { + $this->assertSame($expected_big_pipe_placeholders[$placeholder], $processed_placeholders[$placeholder], "Verifying how BigPipeStrategy handles the placeholder '$placeholder'"); + } + } + else { + $this->assertSame(0, count($processed_placeholders)); + } + } + + /** + * @see \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases + */ + public function placeholdersProvider() { + $cases = \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases::cases(); + + // Generate $placeholders variable as expected by + // \Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface::processPlaceholders(). + $placeholders = [ + $cases['html']->placeholder => $cases['html']->placeholderRenderArray, + $cases['html_attribute_value']->placeholder => $cases['html_attribute_value']->placeholderRenderArray, + $cases['html_attribute_value_subset']->placeholder => $cases['html_attribute_value_subset']->placeholderRenderArray, + $cases['edge_case__invalid_html']->placeholder => $cases['edge_case__invalid_html']->placeholderRenderArray, + $cases['edge_case__html_non_lazy_builder']->placeholder => $cases['edge_case__html_non_lazy_builder']->placeholderRenderArray, + ]; + + return [ + '_no_big_pipe absent, no session, no-JS cookie absent' => [$placeholders, FALSE, FALSE, FALSE, []], + '_no_big_pipe absent, no session, no-JS cookie present' => [$placeholders, FALSE, FALSE, TRUE, []], + '_no_big_pipe present, no session, no-JS cookie absent' => [$placeholders, TRUE, FALSE, FALSE, []], + '_no_big_pipe present, no session, no-JS cookie present' => [$placeholders, TRUE, FALSE, TRUE, []], + '_no_big_pipe present, session, no-JS cookie absent' => [$placeholders, TRUE, TRUE, FALSE, []], + '_no_big_pipe present, session, no-JS cookie present' => [$placeholders, TRUE, TRUE, TRUE, []], + '_no_big_pipe absent, session, no-JS cookie absent: (JS-powered) BigPipe placeholder used for HTML placeholders' => [$placeholders, FALSE, TRUE, FALSE, [ + $cases['html']->placeholder => $cases['html']->bigPipePlaceholderRenderArray, + $cases['html_attribute_value']->placeholder => $cases['html_attribute_value']->bigPipeNoJsPlaceholderRenderArray, + $cases['html_attribute_value_subset']->placeholder => $cases['html_attribute_value_subset']->bigPipeNoJsPlaceholderRenderArray, + $cases['edge_case__invalid_html']->placeholder => $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholderRenderArray, + $cases['edge_case__html_non_lazy_builder']->placeholder => $cases['edge_case__html_non_lazy_builder']->bigPipePlaceholderRenderArray, + ]], + '_no_big_pipe absent, session, no-JS cookie present: no-JS BigPipe placeholder used for HTML placeholders' => [$placeholders, FALSE, TRUE, TRUE, [ + $cases['html']->placeholder => $cases['html']->bigPipeNoJsPlaceholderRenderArray, + $cases['html_attribute_value']->placeholder => $cases['html_attribute_value']->bigPipeNoJsPlaceholderRenderArray, + $cases['html_attribute_value_subset']->placeholder => $cases['html_attribute_value_subset']->bigPipeNoJsPlaceholderRenderArray, + $cases['edge_case__invalid_html']->placeholder => $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholderRenderArray, + $cases['edge_case__html_non_lazy_builder']->placeholder => $cases['edge_case__html_non_lazy_builder']->bigPipeNoJsPlaceholderRenderArray, + ]], + ]; + } + +}