diff --git a/core/lib/Drupal/Core/Theme/Registry.php b/core/lib/Drupal/Core/Theme/Registry.php index 07e83ccfa1fbcb98b07dbc698b03518ec385b825..e72de8be75eb29c5804502cf7d9eb0c7b4a1adc7 100644 --- a/core/lib/Drupal/Core/Theme/Registry.php +++ b/core/lib/Drupal/Core/Theme/Registry.php @@ -237,22 +237,46 @@ protected function init($theme_name = NULL) { */ public function get() { $this->init($this->themeName); - if (isset($this->registry[$this->theme->getName()])) { - return $this->registry[$this->theme->getName()]; + if ($cached = $this->cacheGet()) { + return $cached; } - if ($cache = $this->cache->get('theme_registry:' . $this->theme->getName())) { - $this->registry[$this->theme->getName()] = $cache->data; - } - else { - $this->build(); - // Only persist it if all modules are loaded to ensure it is complete. - if ($this->moduleHandler->isLoaded()) { - $this->setCache(); + // If called from inside a Fiber, suspend it, this may allow another code + // path to begin an asynchronous operation before we do the CPU-intensive + // task of building the theme registry. + if (\Fiber::getCurrent() !== NULL) { + \Fiber::suspend(); + // When the Fiber is resumed, check the cache again since it may have been + // built in the meantime, either in this process or via a different + // request altogether. + if ($cached = $this->cacheGet()) { + return $cached; } } + $this->build(); + // Only persist it if all modules are loaded to ensure it is complete. + if ($this->moduleHandler->isLoaded()) { + $this->setCache(); + } return $this->registry[$this->theme->getName()]; } + /** + * Gets the theme registry cache. + * + * @return array|null + */ + protected function cacheGet(): ?array { + $theme_name = $this->theme->getName(); + if (isset($this->registry[$theme_name])) { + return $this->registry[$theme_name]; + } + elseif ($cache = $this->cache->get('theme_registry:' . $theme_name)) { + $this->registry[$theme_name] = $cache->data; + return $this->registry[$theme_name]; + } + return NULL; + } + /** * Returns the incomplete, runtime theme registry. * diff --git a/core/modules/big_pipe/src/Render/BigPipe.php b/core/modules/big_pipe/src/Render/BigPipe.php index 75adfebe3a876a89871e79dd08af84243184d314..eb4b731096788a9da5d08048bd67c8edc30fa19f 100644 --- a/core/modules/big_pipe/src/Render/BigPipe.php +++ b/core/modules/big_pipe/src/Render/BigPipe.php @@ -536,74 +536,104 @@ protected function sendPlaceholders(array $placeholders, array $placeholder_orde $fake_request = $this->requestStack->getMainRequest()->duplicate(); $fake_request->headers->set('Accept', 'application/vnd.drupal-ajax'); + // Create a Fiber for each placeholder. + $fibers = []; + $message_placeholder_id = NULL; foreach ($placeholder_order as $placeholder_id) { if (!isset($placeholders[$placeholder_id])) { continue; } - - // Render the placeholder. $placeholder_render_array = $placeholders[$placeholder_id]; - try { - $elements = $this->renderPlaceholder($placeholder_id, $placeholder_render_array); - } - catch (\Exception $e) { - if ($this->configFactory->get('system.logging')->get('error_level') === ERROR_REPORTING_DISPLAY_VERBOSE) { - throw $e; - } - else { - trigger_error($e, E_USER_ERROR); - continue; - } - } - // 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->query->set('ajax_page_state', ['libraries' => implode(',', $cumulative_assets->getAlreadyLoadedLibraries())] + $cumulative_assets->getSettings()['ajaxPageState']); - try { - $ajax_response = $this->filterEmbeddedResponse($fake_request, $ajax_response); + // Ensure the messages placeholder renders last, the render order of every + // other placeholder is safe to change. + // @see static::getPlaceholderOrder() + if (isset($placeholder_render_array['#lazy_builder']) && $placeholder_render_array['#lazy_builder'][0] === 'Drupal\Core\Render\Element\StatusMessages::renderMessages') { + $message_placeholder_id = $placeholder_id; } - catch (\Exception $e) { - if ($this->configFactory->get('system.logging')->get('error_level') === ERROR_REPORTING_DISPLAY_VERBOSE) { - throw $e; - } - else { - trigger_error($e, E_USER_ERROR); + $fibers[$placeholder_id] = new \Fiber(fn() => $this->renderPlaceholder($placeholder_id, $placeholder_render_array)); + } + while (count($fibers) > 0) { + $iterations = 0; + foreach ($fibers as $placeholder_id => $fiber) { + // Keep skipping the messages placeholder until it's the only Fiber + // remaining. @todo https://www.drupal.org/project/drupal/issues/3379885 + if (isset($message_placeholder_id) && $placeholder_id === $message_placeholder_id && count($fibers) > 1) { continue; } - } - - // Send this embedded AJAX response. - $json = $ajax_response->getContent(); - $output = <<<EOF - <script type="application/vnd.drupal-ajax" data-big-pipe-replacement-for-placeholder-with-id="$placeholder_id"> - $json - </script> + try { + if (!$fiber->isStarted()) { + $fiber->start(); + } + elseif ($fiber->isSuspended()) { + $fiber->resume(); + } + // If the Fiber hasn't terminated by this point, move onto the next + // placeholder, we'll resume this Fiber again when we get back here. + if (!$fiber->isTerminated()) { + // If we've gone through the placeholders once already, and they're + // still not finished, then start to allow code higher up the stack + // to get on with something else. + if ($iterations) { + $fiber = \Fiber::getCurrent(); + if ($fiber !== NULL) { + $fiber->suspend(); + } + } + continue; + } + $elements = $fiber->getReturn(); + unset($fibers[$placeholder_id]); + // 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->query->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/vnd.drupal-ajax" data-big-pipe-replacement-for-placeholder-with-id="$placeholder_id"> +$json +</script> EOF; - $this->sendChunk($output); - - // 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'])); + $this->sendChunk($output); + + // 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'])); + } + } + catch (\Exception $e) { + unset($fibers[$placeholder_id]); + if ($this->configFactory->get('system.logging')->get('error_level') === ERROR_REPORTING_DISPLAY_VERBOSE) { + throw $e; + } + else { + trigger_error($e, E_USER_ERROR); + } + } } + $iterations++; } // Send the stop signal. @@ -696,7 +726,7 @@ protected function renderPlaceholder($placeholder, array $placeholder_render_arr /** * Gets the BigPipe placeholder order. * - * Determines the order in which BigPipe placeholders must be replaced. + * Determines the order in which BigPipe placeholders are executed. * * @param string $html * HTML markup. @@ -705,10 +735,14 @@ protected function renderPlaceholder($placeholder, array $placeholder_render_arr * placeholder IDs. * * @return array - * Indexed array; the order in which the BigPipe placeholders must be sent. - * Values are the BigPipe placeholder IDs. Note that only unique - * placeholders are kept: if the same placeholder occurs multiple times, we - * only keep the first occurrence. + * Indexed array; the order in which the BigPipe placeholders will start + * execution. Placeholders begin execution in DOM order, except for the + * messages placeholder which must always be executed last. Note that due to + * the Fibers implementation of BigPipe, although placeholders will start + * executing in DOM order, they may finish and render in any order. Values + * are the BigPipe placeholder IDs. Note that only unique placeholders are + * kept: if the same placeholder occurs multiple times, we only keep the + * first occurrence. */ protected function getPlaceholderOrder($html, $placeholders) { $placeholder_ids = []; diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php index 602e1118ed810f7acb03f1347af817d0410e0ae0..44ffc8ccc3bda9ab9fbec8043d0b3ca297baaec6 100644 --- a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php +++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php @@ -4,6 +4,7 @@ * @file */ // cSpell:ignore Vxezb +// cSpell:ignore divpiggydiv namespace Drupal\big_pipe_test; @@ -188,7 +189,63 @@ public static function cases(ContainerInterface $container = NULL, AccountInterf ]; $hello->embeddedHtmlResponse = '<marquee>Yarhar llamas forever!</marquee>'; - // 5. Edge case: non-#lazy_builder placeholder. + // 5. Edge case: non-#lazy_builder placeholder that calls Fiber::suspend(). + $piggy = new BigPipePlaceholderTestCase( + [ + '#markup' => BigPipeMarkup::create('<div>piggy</div>'), + '#attached' => [ + 'placeholders' => [ + '<div>piggy</div>' => [ + '#pre_render' => [ + '\Drupal\big_pipe_test\BigPipeTestController::piggy', + ], + ], + ], + ], + ], + '<div>piggy</div>', + [] + ); + $piggy->bigPipePlaceholderId = 'divpiggydiv'; + $piggy->bigPipePlaceholderRenderArray = [ + '#prefix' => '<span data-big-pipe-placeholder-id="divpiggydiv">', + 'interface_preview' => [], + '#suffix' => '</span>', + '#cache' => $cacheability_depends_on_session_and_nojs_cookie, + '#attached' => [ + 'library' => ['big_pipe/big_pipe'], + 'drupalSettings' => [ + 'bigPipePlaceholderIds' => [ + 'divpiggydiv' => TRUE, + ], + ], + 'big_pipe_placeholders' => [ + 'divpiggydiv' => $piggy->placeholderRenderArray, + ], + ], + ]; + $piggy->embeddedAjaxResponseCommands = [ + [ + 'command' => 'insert', + 'method' => 'replaceWith', + 'selector' => '[data-big-pipe-placeholder-id="divpiggydiv"]', + 'data' => '<span>This 🷠little 🽠piggy 🖠stayed 🽠at 🷠home.</span>', + 'settings' => NULL, + ], + ]; + $piggy->bigPipeNoJsPlaceholder = '<span data-big-pipe-nojs-placeholder-id="divpiggydiv></span>'; + $piggy->bigPipeNoJsPlaceholderRenderArray = [ + '#markup' => '<span data-big-pipe-nojs-placeholder-id="divpiggydiv"></span>', + '#cache' => $cacheability_depends_on_session_and_nojs_cookie, + '#attached' => [ + 'big_pipe_nojs_placeholders' => [ + '<span data-big-pipe-nojs-placeholder-id="divpiggydiv"></span>' => $piggy->placeholderRenderArray, + ], + ], + ]; + $piggy->embeddedHtmlResponse = '<span>This 🷠little 🽠piggy 🖠stayed 🽠at 🷠home.</span>'; + + // 6. Edge case: non-#lazy_builder placeholder. $current_time = new BigPipePlaceholderTestCase( [ '#markup' => BigPipeMarkup::create('<time>CURRENT TIME</time>'), @@ -249,7 +306,7 @@ public static function cases(ContainerInterface $container = NULL, AccountInterf ]; $current_time->embeddedHtmlResponse = '<time datetime="1991-03-14"></time>'; - // 6. Edge case: #lazy_builder that throws an exception. + // 7. Edge case: #lazy_builder that throws an exception. $exception = new BigPipePlaceholderTestCase( [ '#lazy_builder' => ['\Drupal\big_pipe_test\BigPipeTestController::exception', ['llamas', 'suck']], @@ -297,7 +354,7 @@ public static function cases(ContainerInterface $container = NULL, AccountInterf // cSpell:disable-next-line. $token = 'PxOHfS_QL-T01NjBgu7Z7I04tIwMp6La5vM-mVxezbU'; - // 7. Edge case: response filter throwing an exception for this placeholder. + // 8. Edge case: response filter throwing an exception for this placeholder. $embedded_response_exception = new BigPipePlaceholderTestCase( [ '#lazy_builder' => ['\Drupal\big_pipe_test\BigPipeTestController::responseException', []], @@ -348,6 +405,7 @@ public static function cases(ContainerInterface $container = NULL, AccountInterf 'html_attribute_value' => $form_action, 'html_attribute_value_subset' => $csrf_token, 'edge_case__invalid_html' => $hello, + 'edge_case__html_non_lazy_builder_suspend' => $piggy, 'edge_case__html_non_lazy_builder' => $current_time, 'exception__lazy_builder' => $exception, 'exception__embedded_response' => $embedded_response_exception, 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 index 6e6ea88f6a9b4da5523a092c552418e2adb0aa0f..8a0e9c9d9fa6a2f6e6fb7f9b536738c9b9cc4b6d 100644 --- 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 @@ -39,13 +39,16 @@ public function test() { // happens to not be valid HTML. $build['edge_case__invalid_html'] = $cases['edge_case__invalid_html']->renderArray; - // 5. Edge case: non-#lazy_builder placeholder. + // 5. Edge case: non-#lazy_builder placeholder that suspends. + $build['edge_case__html_non_lazy_builder_suspend'] = $cases['edge_case__html_non_lazy_builder_suspend']->renderArray; + + // 6. Edge case: non-#lazy_builder placeholder. $build['edge_case__html_non_lazy_builder'] = $cases['edge_case__html_non_lazy_builder']->renderArray; - // 6. Exception: #lazy_builder that throws an exception. + // 7. Exception: #lazy_builder that throws an exception. $build['exception__lazy_builder'] = $cases['exception__lazy_builder']->renderArray; - // 7. Exception: placeholder that causes response filter to throw exception. + // 8. Exception: placeholder that causes response filter to throw exception. $build['exception__embedded_response'] = $cases['exception__embedded_response']->renderArray; return $build; @@ -127,6 +130,24 @@ public static function currentTime() { ]; } + /** + * #lazy_builder callback; suspends its own execution then returns markup. + * + * @return array + */ + public static function piggy(): array { + // Immediately call Fiber::suspend(), so that other placeholders are + // executed next. When this is resumed, it will immediately return the + // render array. + if (\Fiber::getCurrent() !== NULL) { + \Fiber::suspend(); + } + return [ + '#markup' => '<span>This 🷠little 🽠piggy 🖠stayed 🽠at 🷠home.</span>', + '#cache' => ['max-age' => 0], + ]; + } + /** * #lazy_builder callback; says "hello" or "yarhar". * @@ -193,7 +214,7 @@ public static function counter() { * {@inheritdoc} */ public static function trustedCallbacks() { - return ['currentTime', 'helloOrYarhar', 'exception', 'responseException', 'counter']; + return ['currentTime', 'piggy', 'helloOrYarhar', 'exception', 'responseException', 'counter']; } } diff --git a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php index db7d610d41c33abf38d5f5b53cb46606373d713e..2a31e7bcedcf4d03e0414e90dcd6ac0aaa2a8fb4 100644 --- a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php +++ b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php @@ -170,14 +170,19 @@ public function testBigPipe() { ]); $this->assertBigPipePlaceholders([ $cases['html']->bigPipePlaceholderId => Json::encode($cases['html']->embeddedAjaxResponseCommands), + $cases['edge_case__html_non_lazy_builder_suspend']->bigPipePlaceholderId => Json::encode($cases['edge_case__html_non_lazy_builder_suspend']->embeddedAjaxResponseCommands), $cases['edge_case__html_non_lazy_builder']->bigPipePlaceholderId => Json::encode($cases['edge_case__html_non_lazy_builder']->embeddedAjaxResponseCommands), $cases['exception__lazy_builder']->bigPipePlaceholderId => NULL, $cases['exception__embedded_response']->bigPipePlaceholderId => NULL, ], [ 0 => $cases['edge_case__html_non_lazy_builder']->bigPipePlaceholderId, - // The 'html' case contains the 'status messages' placeholder, which is + // The suspended placeholder is replaced after the non-suspended + // placeholder even though it appears first in the page. + // @see Drupal\big_pipe\Render\BigPipe\Render::sendPlaceholders() + 1 => $cases['edge_case__html_non_lazy_builder_suspend']->bigPipePlaceholderId, + // The 'html' case contains the 'status messages' placeholder, which is // always rendered last. - 1 => $cases['html']->bigPipePlaceholderId, + 2 => $cases['html']->bigPipePlaceholderId, ]); $this->assertSession()->responseContains('</body>'); diff --git a/core/modules/system/tests/modules/render_placeholder_message_test/src/RenderPlaceholderMessageTestController.php b/core/modules/system/tests/modules/render_placeholder_message_test/src/RenderPlaceholderMessageTestController.php index d59ed4eddafe92b44ae68636331e82b3f50d1c49..be32aaa5f2aeebfbffc356305f310d1c59dce115 100644 --- a/core/modules/system/tests/modules/render_placeholder_message_test/src/RenderPlaceholderMessageTestController.php +++ b/core/modules/system/tests/modules/render_placeholder_message_test/src/RenderPlaceholderMessageTestController.php @@ -91,6 +91,13 @@ protected function build(array $placeholder_order) { * A renderable array containing the message. */ public static function setAndLogMessage($message) { + // Ensure that messages are rendered last even when earlier placeholders + // suspend the Fiber, this will cause BigPipe::renderPlaceholders() to loop + // around all of the fibers before resuming this one, then finally rendering + // the messages when there are no other placeholders left. + if (\Fiber::getCurrent() !== NULL) { + \Fiber::suspend(); + } // Set message. \Drupal::messenger()->addStatus($message);