Skip to content
Snippets Groups Projects
Verified Commit 108150c6 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3377570 by catch, Wim Leers, mglaman, marcelovani, Kingdutch: Add PHP...

Issue #3377570 by catch, Wim Leers, mglaman, marcelovani, Kingdutch: Add PHP Fibers support to BigPipe
parent 6a023838
No related branches found
No related tags found
40 merge requests!54479.5.x SF update,!5014Issue #3071143: Table Render Array Example Is Incorrect,!3878Removed unused condition head title for views,!38582585169-10.1.x,!3818Issue #2140179: $entity->original gets stale between updates,!3742Issue #3328429: Create item list field formatter for displaying ordered and unordered lists,!3731Claro: role=button on status report items,!3668Resolve #3347842 "Deprecate the trusted",!3651Issue #3347736: Create new SDC component for Olivero (header-search),!3546refactored dialog.pcss file,!3531Issue #3336994: StringFormatter always displays links to entity even if the user in context does not have access,!3502Issue #3335308: Confusing behavior with FormState::setFormState and FormState::setMethod,!3452Issue #3332701: Refactor Claro's tablesort-indicator stylesheet,!3451Issue #2410579: Allows setting the current language programmatically.,!3355Issue #3209129: Scrolling problems when adding a block via layout builder,!3226Issue #2987537: Custom menu link entity type should not declare "bundle" entity key,!3154Fixes #2987987 - CSRF token validation broken on routes with optional parameters.,!3147Issue #3328457: Replace most substr($a, $i) where $i is negative with str_ends_with(),!3146Issue #3328456: Replace substr($a, 0, $i) with str_starts_with(),!3133core/modules/system/css/components/hidden.module.css,!31312878513-10.1.x,!2964Issue #2865710 : Dependencies from only one instance of a widget are used in display modes,!2812Issue #3312049: [Followup] Fix Drupal.Commenting.FunctionComment.MissingReturnType returns for NULL,!2614Issue #2981326: Replace non-test usages of \Drupal::logger() with IoC injection,!2378Issue #2875033: Optimize joins and table selection in SQL entity query implementation,!2334Issue #3228209: Add hasRole() method to AccountInterface,!2062Issue #3246454: Add weekly granularity to views date sort,!1591Issue #3199697: Add JSON:API Translation experimental module,!1255Issue #3238922: Refactor (if feasible) uses of the jQuery serialize function to use vanillaJS,!1105Issue #3025039: New non translatable field on translatable content throws error,!1073issue #3191727: Focus states on mobile second level navigation items fixed,!10223132456: Fix issue where views instances are emptied before an ajax request is complete,!877Issue #2708101: Default value for link text is not saved,!844Resolve #3036010 "Updaters",!673Issue #3214208: FinishResponseSubscriber could create duplicate headers,!617Issue #3043725: Provide a Entity Handler for user cancelation,!579Issue #2230909: Simple decimals fail to pass validation,!560Move callback classRemove outside of the loop,!555Issue #3202493,!485Sets the autocomplete attribute for username/password input field on login form.
......@@ -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.
*
......
......@@ -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 = [];
......
......@@ -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,
......
......@@ -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'];
}
}
......@@ -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>');
......
......@@ -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);
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment