.../HtmlResponseBigPipeSubscriber.php | 27 ++- core/modules/big_pipe/src/Render/BigPipe.php | 195 ++++++++++++--------- .../big_pipe/src/Render/BigPipeInterface.php | 71 ++++++++ .../big_pipe/src/Render/BigPipeResponse.php | 2 +- .../Render/BigPipeResponseAttachmentsProcessor.php | 2 +- .../src/Render/Placeholder/BigPipeStrategy.php | 2 +- 6 files changed, 205 insertions(+), 94 deletions(-) diff --git a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php index 2af2dbe..98a41cb 100644 --- a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php +++ b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php @@ -17,7 +17,7 @@ /** * Response subscriber to replace the HtmlResponse with a BigPipeResponse. * - * @see \Drupal\big_pipe\Render\BigPipe + * @see \Drupal\big_pipe\Render\BigPipeInterface * * @todo Refactor once https://www.drupal.org/node/2577631 lands. */ @@ -52,15 +52,14 @@ public function onRespondEarly(FilterResponseEvent $event) { return; } - // Set a marker around 'scripts_bottom' + // 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(); - - // Wrap the scripts_bottom placeholder with a marker before and after, - // because \Drupal\big_pipe\Render\BigPipe needs to be able to parse out - // that placeholder for the "HalfPipe" render strategy. $content = str_replace($scripts_bottom_placeholder, '' . $scripts_bottom_placeholder . '', $content); $response->setContent($content); } @@ -83,15 +82,25 @@ public function onRespond(FilterResponseEvent $event) { } $attachments = $response->getAttachments(); - if (empty($attachments['big_pipe_placeholders']) && empty($attachments['big_pipe_nojs_placeholders'])) { - // Remove our marker again. + + // 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('', '', $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; } - // Create a new Response. + // Create a new BigPipeResponse. $big_pipe_response = new BigPipeResponse(); $big_pipe_response->setBigPipeService($this->bigPipe); diff --git a/core/modules/big_pipe/src/Render/BigPipe.php b/core/modules/big_pipe/src/Render/BigPipe.php index d7b5a61..15f1c10 100644 --- a/core/modules/big_pipe/src/Render/BigPipe.php +++ b/core/modules/big_pipe/src/Render/BigPipe.php @@ -11,6 +11,7 @@ use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Ajax\ReplaceCommand; use Drupal\Core\Asset\AttachedAssets; +use Drupal\Core\Asset\AttachedAssetsInterface; use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\Render\RendererInterface; use Drupal\Core\Render\AttachmentsResponseProcessorInterface; @@ -25,8 +26,7 @@ use Symfony\Component\HttpKernel\KernelEvents; /** - * A class that allows sending the main content first, then replace - * placeholders to send the rest using Javascript replacements. + * The default BigPipe service. */ class BigPipe implements BigPipeInterface { @@ -91,87 +91,108 @@ public function __construct(RendererInterface $renderer, SessionInterface $sessi * {@inheritdoc} */ public function sendContent($content, array $attachments) { - // We are sending a BigPipeResponse in this method. A BigPipeResponse is an - // aggregated response: it consists of a HtmlResponse plus multiple embedded - // AjaxResponses. The embedded AjaxResponses are generated here, in this - // method: one for each placeholder that needs to be replaced. This means - // that each AjaxResponse needs to be aware of the asset libraries that have - // already been loaded by the initial HtmlResponse plus all the preceding - // AjaxResponses. An AttachedAssetsInterface object is a perfect way to - // track this over time. - $assets = AttachedAssets::createFromRenderArray(['#attached' => $attachments]); - $assets->setAlreadyLoadedLibraries(explode(',', $attachments['drupalSettings']['ajaxPageState']['libraries'])); + // 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(explode(',', $attachments['drupalSettings']['ajaxPageState']['libraries'])); // 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(); - // Extract the scripts_bottom markup; the HalfPipe render strategy needs to - // be able to update it. - $t = explode('', $content, 3); - assert('count($t) == 3', 'There is content before and after scripts_bottom.'); - $scripts_bottom = $t[1]; - unset($t[1]); - $content = implode('', $t); - - // Split it up in various chunks. - $page_parts = explode('', $content); - if (count($page_parts) !== 2) { - throw new \LogicException("You need to have only one body or one tag in your html.html.twig template file."); + list($pre_body, $post_body) = explode('', $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 . + * + * @param string $pre_body + * The HTML response's content until the closing 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- + // part of the page immediately. + if (empty($no_js_placeholders)) { + print $pre_body; + flush(); + return; } - $placeholders = isset($attachments['big_pipe_placeholders']) ? $attachments['big_pipe_placeholders'] : []; - $half_pipe_placeholders = isset($attachments['big_pipe_nojs_placeholders']) ? $attachments['big_pipe_nojs_placeholders'] : []; - - if (!empty($half_pipe_placeholders)) { - $extra_attachments = $this->doHalfPipe($page_parts[0], $half_pipe_placeholders); - // Print the extra attachments. - if (!empty($extra_attachments['library']) || !empty($extra_attachments['drupalSettings'])) { - $all_attachments = BubbleableMetadata::mergeAttachments($attachments, $extra_attachments); - - // Update the extra libraries using the Response's ajax page state. - // In the ideal case this will be empty and all libraries have been - // to the bottom js section already. - $variables_extra = $this->htmlResponseAttachmentsProcessor->processAssetLibraries($extra_attachments, [ 'scripts' => 'TRUE', 'styles' => TRUE ], $this->ajaxPageState); - if (!empty($variables_extra['styles'])) { - print $this->renderer->renderRoot($variables_extra['styles']); - } - if (!empty($variables_extra['styles'])) { - print $this->renderer->renderRoot($variables_extra['scripts']); - } - - // Now that the placeholders have been rendered using the HalfPipe - // render strategy, recalculate the scripts_bottom markup. - $variables = $this->htmlResponseAttachmentsProcessor->processAssetLibraries($all_attachments, [ 'scripts_bottom' => TRUE ]); - $scripts_bottom = $this->renderer->renderRoot($variables['scripts_bottom']); + // 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('', $pre_body, 3); + + $extra_attachments = $this->doHalfPipe($pre_scripts_bottom . $post_scripts_bottom, $no_js_placeholders); + // Print the extra attachments. + if (!empty($extra_attachments['library']) || !empty($extra_attachments['drupalSettings'])) { + $all_attachments = BubbleableMetadata::mergeAttachments($attachments, $extra_attachments); + + // Update the extra libraries using the Response's ajax page state. + // In the ideal case this will be empty and all libraries have been + // to the bottom js section already. + $variables_extra = $this->htmlResponseAttachmentsProcessor->processAssetLibraries($extra_attachments, [ 'scripts' => 'TRUE', 'styles' => TRUE ], $this->ajaxPageState); + if (!empty($variables_extra['styles'])) { + print $this->renderer->renderRoot($variables_extra['styles']); + } + if (!empty($variables_extra['styles'])) { + print $this->renderer->renderRoot($variables_extra['scripts']); } - } - else { - print $page_parts[0]; - ob_end_flush(); - } - // Send the JavaScript at the bottom of the page. + // Now that the placeholders have been rendered using the HalfPipe + // render strategy, recalculate the scripts_bottom markup. + $variables = $this->htmlResponseAttachmentsProcessor->processAssetLibraries($all_attachments, [ 'scripts_bottom' => TRUE ]); + $scripts_bottom = $this->renderer->renderRoot($variables['scripts_bottom']); + } print $scripts_bottom; - flush(); + } + /** + * Sends BigPipe placeholders' replacements as embedded AJAX responses. + * + * @param array $placeholders + * Associative array; the BigPipe placeholders. Keys are the BigPipe + * selectors. + * @param array $placeholder_order + * Indexed array; the order in which the BigPipe placeholders must be sent. + * Values are the BigPipe selectors. (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)) { - print ''; - print $page_parts[1]; return; } - // Print a container and the start signal. + // Send a container and the start signal. print "\n"; print '' . "\n"; - flush(); - // Sort placeholders by the order in which they appear in the markup. - $order = $this->getPlaceholderOrder($content); - // 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 @@ -182,20 +203,14 @@ public function sendContent($content, array $attachments) { $fake_request = $this->requestStack->getMasterRequest()->duplicate(); $fake_request->headers->set('Accept', 'application/json'); - foreach ($order as $placeholder) { + foreach ($placeholder_order as $placeholder) { if (!isset($placeholders[$placeholder])) { continue; } - // Check if the placeholder is present at all. - if (strpos($content, $placeholder) === FALSE) { - continue; - } - - $placeholder_elements = $placeholders[$placeholder]; - // Render the placeholder. - $elements = $this->renderPlaceholder($placeholder, $placeholder_elements); + $placeholder_render_array = $placeholders[$placeholder]; + $elements = $this->renderPlaceholder($placeholder, $placeholder_render_array); // Create a new AjaxResponse. $ajax_response = new AjaxResponse(); @@ -216,7 +231,7 @@ public function sendContent($content, array $attachments) { // - 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(',', $assets->getAlreadyLoadedLibraries())] + $assets->getSettings()['ajaxPageState']); + $fake_request->request->set('ajax_page_state', ['libraries' => implode(',', $cumulative_assets->getAlreadyLoadedLibraries())] + $cumulative_assets->getSettings()['ajaxPageState']); $this->requestStack->push($fake_request); $event = new FilterResponseEvent($this->httpKernel, $fake_request, HttpKernelInterface::SUB_REQUEST, $ajax_response); $this->eventDispatcher->dispatch(KernelEvents::RESPONSE, $event); @@ -229,25 +244,30 @@ public function sendContent($content, array $attachments) { - EOF; print $output; flush(); // Another placeholder was rendered and sent, track the set of asset // libraries sent so far. - $assets->setAlreadyLoadedLibraries(explode(',', $ajax_response->getAttachments()['drupalSettings']['ajaxPageState']['libraries'])); + $cumulative_assets->setAlreadyLoadedLibraries(explode(',', $ajax_response->getAttachments()['drupalSettings']['ajaxPageState']['libraries'])); } // Send the stop signal. print '' . "\n"; - print ''; - print $page_parts[1]; - - // Close the session again. - $this->session->save(); + flush(); + } - return $this; + /** + * Sends and everything after it. + * + * @param string $post_body + * The HTML response's content after the closing tag. + */ + protected function sendPostBody($post_body) { + print ''; + print $post_body; + flush(); } /** @@ -277,8 +297,19 @@ protected function renderPlaceholder($placeholder, $placeholder_elements) { return $this->renderer->renderPlaceholder($placeholder, $elements); } - protected function getPlaceholderOrder($content, $selector = '
— 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