Skip to content

Commit 78efb6e

Browse files
ENGCOM-7678: Fix duplicate css when enable critical path #28480
- Merge Pull Request #28480 from mrtuvn/magento2:fix-duplicate-css-when-enable-critical-path - Merged commits: 1. bacb817
2 parents 237c2b1 + bacb817 commit 78efb6e

File tree

7 files changed

+203
-94
lines changed

7 files changed

+203
-94
lines changed

app/code/Magento/Theme/Controller/Result/AsyncCssPlugin.php

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
use Magento\Framework\App\Config\ScopeConfigInterface;
1111
use Magento\Store\Model\ScopeInterface;
1212
use Magento\Framework\App\Response\Http;
13+
use Magento\Framework\App\Response\HttpInterface as HttpResponseInterface;
14+
use Magento\Framework\App\ResponseInterface;
15+
use Magento\Framework\View\Result\Layout;
1316

1417
/**
1518
* Plugin for asynchronous CSS loading.
@@ -32,48 +35,94 @@ public function __construct(ScopeConfigInterface $scopeConfig)
3235
}
3336

3437
/**
35-
* Load CSS asynchronously if it is enabled in configuration.
38+
* Extracts styles to head after critical css if critical path feature is enabled.
3639
*
37-
* @param Http $subject
38-
* @return void
40+
* @param Layout $subject
41+
* @param Layout $result
42+
* @param HttpResponseInterface|ResponseInterface $httpResponse
43+
* @return Layout (That should be void, actually)
44+
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
3945
*/
40-
public function beforeSendResponse(Http $subject): void
46+
public function afterRenderResult(Layout $subject, Layout $result, ResponseInterface $httpResponse)
4147
{
42-
$content = $subject->getContent();
48+
if (!$this->isCssCriticalEnabled()) {
49+
return $result;
50+
}
4351

44-
if (\is_string($content) && strpos($content, '</body') !== false && $this->scopeConfig->isSetFlag(
45-
self::XML_PATH_USE_CSS_CRITICAL_PATH,
46-
ScopeInterface::SCOPE_STORE
47-
)) {
48-
$cssMatches = [];
49-
// add link rel preload to style sheets
50-
$content = preg_replace_callback(
51-
'@<link\b.*?rel=("|\')stylesheet\1.*?/>@',
52-
function ($matches) use (&$cssMatches) {
53-
$cssMatches[] = $matches[0];
54-
preg_match('@href=("|\')(.*?)\1@', $matches[0], $hrefAttribute);
55-
$href = $hrefAttribute[2];
56-
if (preg_match('@media=("|\')(.*?)\1@', $matches[0], $mediaAttribute)) {
57-
$media = $mediaAttribute[2];
58-
}
59-
$media = $media ?? 'all';
60-
$loadCssAsync = sprintf(
61-
'<link rel="preload" as="style" media="%s"' .
62-
' onload="this.onload=null;this.rel=\'stylesheet\'"' .
63-
' href="%s" />',
64-
$media,
65-
$href
66-
);
67-
68-
return $loadCssAsync;
69-
},
70-
$content
71-
);
52+
$content = (string)$httpResponse->getContent();
53+
$headCloseTag = '</head>';
7254

73-
if (!empty($cssMatches)) {
74-
$content = str_replace('</body', implode("\n", $cssMatches) . "\n</body", $content);
75-
$subject->setContent($content);
55+
$headEndTagFound = strpos($content, $headCloseTag) !== false;
56+
57+
if ($headEndTagFound) {
58+
$styles = $this->extractLinkTags($content);
59+
if ($styles) {
60+
$newHeadEndTagPosition = strrpos($content, $headCloseTag);
61+
$content = substr_replace($content, $styles . "\n", $newHeadEndTagPosition, 0);
62+
$httpResponse->setContent($content);
7663
}
7764
}
65+
66+
return $result;
67+
}
68+
69+
/**
70+
* Extracts link tags found in given content.
71+
*
72+
* @param string $content
73+
*/
74+
private function extractLinkTags(string &$content): string
75+
{
76+
$styles = '';
77+
$styleOpen = '<link';
78+
$styleClose = '>';
79+
$styleOpenPos = strpos($content, $styleOpen);
80+
81+
while ($styleOpenPos !== false) {
82+
$styleClosePos = strpos($content, $styleClose, $styleOpenPos);
83+
$style = substr($content, $styleOpenPos, $styleClosePos - $styleOpenPos + strlen($styleClose));
84+
85+
if (!preg_match('@rel=["\']stylesheet["\']@', $style)) {
86+
// Link is not a stylesheet, search for another one after it.
87+
$styleOpenPos = strpos($content, $styleOpen, $styleClosePos);
88+
continue;
89+
}
90+
// Remove the link from HTML to add it before </head> tag later.
91+
$content = str_replace($style, '', $content);
92+
93+
if (!preg_match('@href=("|\')(.*?)\1@', $style, $hrefAttribute)) {
94+
throw new \RuntimeException("Invalid link {$style} syntax provided");
95+
}
96+
$href = $hrefAttribute[2];
97+
98+
if (preg_match('@media=("|\')(.*?)\1@', $style, $mediaAttribute)) {
99+
$media = $mediaAttribute[2];
100+
}
101+
$media = $media ?? 'all';
102+
103+
$style = sprintf(
104+
'<link rel="stylesheet" media="print" onload="this.onload=null;this.media=\'%s\'" href="%s">',
105+
$media,
106+
$href
107+
);
108+
$styles .= "\n" . $style;
109+
// Link was cut out, search for the next one at its former position.
110+
$styleOpenPos = strpos($content, $styleOpen, $styleOpenPos);
111+
}
112+
113+
return $styles;
114+
}
115+
116+
/**
117+
* Returns information whether css critical path is enabled
118+
*
119+
* @return bool
120+
*/
121+
private function isCssCriticalEnabled(): bool
122+
{
123+
return $this->scopeConfig->isSetFlag(
124+
self::XML_PATH_USE_CSS_CRITICAL_PATH,
125+
ScopeInterface::SCOPE_STORE
126+
);
78127
}
79128
}

app/code/Magento/Theme/Test/Unit/Controller/Result/AsyncCssPluginTest.php

Lines changed: 95 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77

88
namespace Magento\Theme\Test\Unit\Controller\Result;
99

10-
use Magento\Framework\App\Config\ScopeConfigInterface;
11-
use Magento\Framework\App\Response\Http;
12-
use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper;
13-
use Magento\Store\Model\ScopeInterface;
1410
use Magento\Theme\Controller\Result\AsyncCssPlugin;
15-
use PHPUnit\Framework\MockObject\MockObject;
11+
use Magento\Framework\App\Response\Http;
1612
use PHPUnit\Framework\TestCase;
13+
use PHPUnit\Framework\MockObject\MockObject;
14+
use Magento\Framework\App\Config\ScopeConfigInterface;
15+
use Magento\Store\Model\ScopeInterface;
16+
use Magento\Framework\View\Result\Layout;
17+
use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper;
1718

1819
/**
1920
* Unit test for Magento\Theme\Test\Unit\Controller\Result\AsyncCssPlugin.
@@ -37,6 +38,9 @@ class AsyncCssPluginTest extends TestCase
3738
*/
3839
private $httpMock;
3940

41+
/** @var Layout|MockObject */
42+
private $layoutMock;
43+
4044
/**
4145
* @inheritdoc
4246
*/
@@ -48,6 +52,7 @@ protected function setUp(): void
4852
->getMockForAbstractClass();
4953

5054
$this->httpMock = $this->createMock(Http::class);
55+
$this->layoutMock = $this->createMock(Layout::class);
5156

5257
$objectManager = new ObjectManagerHelper($this);
5358
$this->plugin = $objectManager->getObject(
@@ -59,87 +64,134 @@ protected function setUp(): void
5964
}
6065

6166
/**
62-
* Data Provider for before send response
67+
* Data Provider for testAfterRenderResult
6368
*
6469
* @return array
6570
*/
66-
public function sendResponseDataProvider(): array
71+
public function renderResultDataProvider(): array
6772
{
6873
return [
6974
[
70-
"content" => "<body><h1>Test Title</h1>" .
71-
"<link rel=\"stylesheet\" href=\"css/critical.css\" />" .
72-
"<p>Test Content</p></body>",
75+
"content" => "<head><link rel=\"stylesheet\" href=\"css/async.css\">" .
76+
"<style>.critical-css{}</style>" .
77+
"</head>",
78+
"flag" => true,
79+
"result" => "<head><style>.critical-css{}</style>\n" .
80+
"<link " .
81+
"rel=\"stylesheet\" media=\"print\" onload=\"this.onload=null;this.media='all'\" " .
82+
"href=\"css/async.css\">\n" .
83+
"</head>",
84+
],
85+
[
86+
"content" => "<head><link rel=\"stylesheet\" href=\"css/async.css\">" .
87+
"<link rel=\"preload\" href=\"other-file.html\">" .
88+
"</head>",
7389
"flag" => true,
74-
"result" => "<body><h1>Test Title</h1>" .
75-
"<link rel=\"preload\" as=\"style\" media=\"all\"" .
76-
" onload=\"this.onload=null;this.rel='stylesheet'\" href=\"css/critical.css\" />" .
77-
"<p>Test Content</p>" .
78-
"<link rel=\"stylesheet\" href=\"css/critical.css\" />" .
79-
"\n</body>"
90+
"result" => "<head><link rel=\"preload\" href=\"other-file.html\">\n" .
91+
"<link " .
92+
"rel=\"stylesheet\" media=\"print\" onload=\"this.onload=null;this.media='all'\" " .
93+
"href=\"css/async.css\">\n" .
94+
"</head>",
8095
],
8196
[
82-
"content" => "<body><p>Test Content</p></body>",
97+
"content" => "<head><link rel=\"stylesheet\" href=\"css/async.css\">" .
98+
"<link rel=\"preload\" href=\"other-file.html\">" .
99+
"</head>",
83100
"flag" => false,
84-
"result" => "<body><p>Test Content</p></body>"
101+
"result" => "<head><link rel=\"stylesheet\" href=\"css/async.css\">" .
102+
"<link rel=\"preload\" href=\"other-file.html\">" .
103+
"</head>",
85104
],
86105
[
87-
"content" => "<body><p>Test Content</p></body>",
106+
"content" => "<head><link rel=\"stylesheet\" href=\"css/first.css\">" .
107+
"<link rel=\"stylesheet\" href=\"css/second.css\">" .
108+
"<style>.critical-css{}</style>" .
109+
"</head>",
88110
"flag" => true,
89-
"result" => "<body><p>Test Content</p></body>"
111+
"result" => "<head><style>.critical-css{}</style>\n" .
112+
"<link " .
113+
"rel=\"stylesheet\" media=\"print\" onload=\"this.onload=null;this.media='all'\" " .
114+
"href=\"css/first.css\">\n" .
115+
"<link " .
116+
"rel=\"stylesheet\" media=\"print\" onload=\"this.onload=null;this.media='all'\" " .
117+
"href=\"css/second.css\">\n" .
118+
"</head>",
119+
],
120+
[
121+
"content" => "<head><style>.critical-css{}</style></head>",
122+
"flag" => false,
123+
"result" => "<head><style>.critical-css{}</style></head>"
124+
],
125+
[
126+
"content" => "<head><style>.critical-css{}</style></head>",
127+
"flag" => true,
128+
"result" => "<head><style>.critical-css{}</style></head>"
90129
]
91130
];
92131
}
93132

94133
/**
95-
* Test beforeSendResponse
134+
* Test after render result response
96135
*
97136
* @param string $content
98137
* @param bool $isSetFlag
99138
* @param string $result
100139
* @return void
101-
* @dataProvider sendResponseDataProvider
140+
* @dataProvider renderResultDataProvider
102141
*/
103-
public function testBeforeSendResponse($content, $isSetFlag, $result): void
142+
public function testAfterRenderResult(string $content, bool $isSetFlag, string $result): void
104143
{
105-
$this->httpMock->expects($this->once())
106-
->method('getContent')
144+
// Given (context)
145+
$this->httpMock->method('getContent')
107146
->willReturn($content);
108147

109-
$this->scopeConfigMock->expects($this->once())
110-
->method('isSetFlag')
111-
->with(
112-
self::STUB_XML_PATH_USE_CSS_CRITICAL_PATH,
113-
ScopeInterface::SCOPE_STORE
114-
)
148+
$this->scopeConfigMock->method('isSetFlag')
149+
->with(self::STUB_XML_PATH_USE_CSS_CRITICAL_PATH, ScopeInterface::SCOPE_STORE)
115150
->willReturn($isSetFlag);
116151

152+
// Expects
117153
$this->httpMock->expects($this->any())
118154
->method('setContent')
119155
->with($result);
120156

121-
$this->plugin->beforeSendResponse($this->httpMock);
157+
// When
158+
$this->plugin->afterRenderResult($this->layoutMock, $this->layoutMock, $this->httpMock);
122159
}
123160

124161
/**
125-
* Test BeforeSendResponse if content is not a string
162+
* Data Provider for testAfterRenderResultIfGetContentIsNotAString()
126163
*
164+
* @return array
165+
*/
166+
public function ifGetContentIsNotAStringDataProvider(): array
167+
{
168+
return [
169+
[
170+
'content' => null
171+
]
172+
];
173+
}
174+
175+
/**
176+
* Test AfterRenderResult if content is not a string
177+
*
178+
* @param $content
127179
* @return void
180+
* @dataProvider ifGetContentIsNotAStringDataProvider
128181
*/
129-
public function testIfGetContentIsNotAString(): void
182+
public function testAfterRenderResultIfGetContentIsNotAString($content): void
130183
{
184+
$this->scopeConfigMock->method('isSetFlag')
185+
->with(self::STUB_XML_PATH_USE_CSS_CRITICAL_PATH, ScopeInterface::SCOPE_STORE)
186+
->willReturn(true);
187+
131188
$this->httpMock->expects($this->once())
132189
->method('getContent')
133-
->willReturn([]);
190+
->willReturn($content);
134191

135-
$this->scopeConfigMock->expects($this->any())
136-
->method('isSetFlag')
137-
->with(
138-
self::STUB_XML_PATH_USE_CSS_CRITICAL_PATH,
139-
ScopeInterface::SCOPE_STORE
140-
)
141-
->willReturn(false);
192+
$this->httpMock->expects($this->never())
193+
->method('setContent');
142194

143-
$this->plugin->beforeSendResponse($this->httpMock);
195+
$this->plugin->afterRenderResult($this->layoutMock, $this->layoutMock, $this->httpMock);
144196
}
145197
}

app/code/Magento/Theme/etc/frontend/di.xml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,9 @@
2626
<type name="Magento\Framework\Controller\ResultInterface">
2727
<plugin name="result-messages" type="Magento\Theme\Controller\Result\MessagePlugin"/>
2828
</type>
29-
<type name="Magento\Framework\App\Response\Http">
30-
<plugin name="asyncCssLoad" type="Magento\Theme\Controller\Result\AsyncCssPlugin"/>
31-
</type>
3229
<type name="Magento\Framework\View\Result\Layout">
33-
<plugin name="deferJsToFooter" type="Magento\Theme\Controller\Result\JsFooterPlugin" sortOrder="-10"/>
30+
<plugin name="asyncCssLoad" type="Magento\Theme\Controller\Result\AsyncCssPlugin" />
31+
<plugin name="deferJsToFooter" type="Magento\Theme\Controller\Result\JsFooterPlugin" sortOrder="-10" />
3432
</type>
3533
<type name="Magento\Theme\Block\Html\Header\CriticalCss">
3634
<arguments>

app/code/Magento/Theme/view/frontend/layout/default_head_blocks.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<argument name="criticalCssViewModel" xsi:type="object">Magento\Theme\Block\Html\Header\CriticalCss</argument>
1919
</arguments>
2020
</block>
21+
<!-- Todo: Block css_rel_preload_script will be removed in next release as polyfill isn't used anymore -->
2122
<block name="css_rel_preload_script" ifconfig="dev/css/use_css_critical_path" template="Magento_Theme::js/css_rel_preload.phtml"/>
2223
</referenceBlock>
2324
<referenceContainer name="after.body.start">

app/code/Magento/Theme/view/frontend/templates/html/main_css_preloader.phtml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44
* See COPYING.txt for license details.
55
*/
66

7-
/** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */
7+
/**
8+
* @var \Magento\Framework\View\Element\Template $block
9+
* @var \Magento\Framework\Escaper $escaper
10+
* @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer
11+
*/
12+
813
?>
914
<div data-role="main-css-loader" class="loading-mask">
1015
<div class="loader">
11-
<img src="<?= $block->escapeUrl($block->getViewFileUrl('images/loader-1.gif')); ?>"
12-
alt="<?= $block->escapeHtml(__('Loading...')); ?>">
16+
<img src="<?= $escaper->escapeUrl($block->getViewFileUrl('images/loader-1.gif')); ?>"
17+
alt="<?= $escaper->escapeHtml(__('Loading...')); ?>">
1318
</div>
1419
<?= /* @noEscape */ $secureRenderer->renderStyleAsTag(
1520
"position: absolute;",

0 commit comments

Comments
 (0)