Skip to content

Commit cc9aa64

Browse files
committed
add offload large css
1 parent 2074911 commit cc9aa64

26 files changed

Lines changed: 2058 additions & 81 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,7 @@ jspm_packages/
8888
.dynamodb/
8989

9090
# TernJS port file
91-
.tern-port
91+
.tern-port
92+
.phpunit.result.cache
93+
composer.lock
94+
package-lock.json

DeferCSS.php

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<?php
2+
3+
namespace React\React;
4+
5+
use Magento\Framework\App\Config\ScopeConfigInterface as Config;
6+
use Magento\Framework\Event\ObserverInterface;
7+
8+
class DeferCSS implements ObserverInterface
9+
{
10+
public function __construct(
11+
protected Config $config
12+
) {
13+
}
14+
15+
public function execute(\Magento\Framework\Event\Observer $observer)
16+
{
17+
// Check if defer CSS is enabled (config or GET parameter)
18+
if (!$this->shouldDeferCSS()) {
19+
return;
20+
}
21+
22+
$response = $observer->getEvent()->getData('response');
23+
if (!$response) {
24+
return;
25+
}
26+
$html = $response->getBody();
27+
if ($html == '') {
28+
return;
29+
}
30+
31+
$html = $this->deferCSS($html);
32+
$response->setBody($html);
33+
}
34+
35+
private function shouldDeferCSS(): bool
36+
{
37+
// Check GET parameter first
38+
if (isset($_GET['defer-css']) && $_GET['defer-css'] === "false") {
39+
return false;
40+
}
41+
if (isset($_GET['defer-css']) && $_GET['defer-css'] === "true") {
42+
return true;
43+
}
44+
45+
// Fall back to config (default to true if not set)
46+
$configValue = $this->config->getValue('react_vue_config/css/defer_css');
47+
return $configValue === null || $configValue === '' ? true : boolval($configValue);
48+
}
49+
50+
private function deferCSS(string $html): string
51+
{
52+
// Match link tags with styles-l.css (handles both /> and > closing, with or without whitespace)
53+
$stylesLPattern = '@<link[^>]*href=["\'][^"\']*styles-l[^"\']*\.css[^"\']*["\'][^>]*\s*/?>@i';
54+
55+
$scriptsToInsert = [];
56+
57+
if (preg_match_all($stylesLPattern, $html, $allMatches, PREG_OFFSET_CAPTURE)) {
58+
// Process matches in reverse order to maintain correct offsets when replacing
59+
$matches = array_reverse($allMatches[0]);
60+
61+
foreach ($matches as $match) {
62+
$stylesLTag = $match[0];
63+
64+
// Check if stylesheet has desktop-only media query (safe to defer on mobile)
65+
if (!$this->hasDesktopMediaQuery($stylesLTag)) {
66+
continue;
67+
}
68+
69+
$href = $this->extractHref($stylesLTag);
70+
$media = $this->extractMedia($stylesLTag);
71+
72+
// Build preload link for desktop (in HTML source)
73+
$preloadLink = '<link rel="preload" as="style" fetchpriority="high" href="' . htmlspecialchars($href, ENT_QUOTES) . '"' . ($media ? ' media="' . htmlspecialchars($media, ENT_QUOTES) . '"' : '') . '>';
74+
75+
// Build the deferred script
76+
// Desktop: uses document.write() for blocking synchronous load before FCP
77+
// Mobile: uses setTimeout with createElement for async delayed load
78+
$deferredScript = '<script no-defer>
79+
(function () {
80+
var isDesktop = window.matchMedia("(min-width: 768px)").matches;
81+
82+
if (isDesktop) {
83+
// Acts as if the <link> was in original HTML → BLOCKS before FCP
84+
document.write(
85+
\'<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES) . '"' . ($media ? ' media="' . htmlspecialchars($media, ENT_QUOTES) . '"' : '') . '>\'
86+
);
87+
} else {
88+
// Mobile: delayed, non-blocking
89+
setTimeout(function () {
90+
var l = document.createElement("link");
91+
l.rel = "stylesheet";
92+
l.href = "' . htmlspecialchars($href, ENT_QUOTES) . '";
93+
' . ($media ? 'l.media = "' . htmlspecialchars($media, ENT_QUOTES) . '";' : '') . '
94+
document.head.appendChild(l);
95+
}, 0);
96+
}
97+
})();
98+
</script>';
99+
100+
// Remove original link tag
101+
$html = str_replace($stylesLTag, '', $html);
102+
103+
// Collect preload link and script to insert after mobile styles
104+
$scriptsToInsert[] = $preloadLink . $deferredScript;
105+
}
106+
}
107+
108+
// Insert scripts after mobile styles (styles-m.css, category-styles-m.min.css, etc.)
109+
// but before desktop styles
110+
if (!empty($scriptsToInsert) && preg_match('/<head[^>]*>/i', $html, $headMatch, PREG_OFFSET_CAPTURE)) {
111+
$headPos = $headMatch[0][1] + strlen($headMatch[0][0]);
112+
$headContent = substr($html, $headPos);
113+
114+
// Find the last mobile stylesheet (styles-m.css or category-styles-m.min.css, etc.)
115+
// Pattern: link tags with styles-m or category-styles-m or product-styles-m, etc.
116+
$mobileStylesPattern = '@<link[^>]*href=["\'][^"\']*(?:styles-m|category-styles-m|product-styles-m|home-styles-m)[^"\']*\.css[^"\']*["\'][^>]*\s*/?>@i';
117+
118+
$insertPosition = 0;
119+
if (preg_match_all($mobileStylesPattern, $headContent, $mobileMatches, PREG_OFFSET_CAPTURE)) {
120+
// Find the position after the last mobile stylesheet
121+
$lastMobileMatch = end($mobileMatches[0]);
122+
$insertPosition = $lastMobileMatch[1] + strlen($lastMobileMatch[0]);
123+
}
124+
125+
// Insert scripts after mobile styles (or at beginning of head if no mobile styles found)
126+
$beforeScripts = substr($headContent, 0, $insertPosition);
127+
$afterScripts = substr($headContent, $insertPosition);
128+
129+
$html = substr($html, 0, $headPos) . $beforeScripts . implode('', $scriptsToInsert) . $afterScripts;
130+
}
131+
132+
return $html;
133+
}
134+
135+
private function hasDesktopMediaQuery(string $linkTag): bool
136+
{
137+
if (preg_match('/media=["\']([^"\']+)["\']/i', $linkTag, $matches)) {
138+
$media = $matches[1];
139+
// Check if media query contains min-width (desktop-only)
140+
return preg_match('/min-width\s*:\s*(\d+)/i', $media) === 1;
141+
}
142+
return false;
143+
}
144+
145+
private function extractMedia(string $linkTag): string
146+
{
147+
if (preg_match('/media=["\']([^"\']+)["\']/i', $linkTag, $matches)) {
148+
return $matches[1];
149+
}
150+
return '';
151+
}
152+
153+
private function extractHref(string $linkTag): string
154+
{
155+
if (preg_match('/href=["\']([^"\']+)["\']/i', $linkTag, $matches)) {
156+
return $matches[1];
157+
}
158+
return '';
159+
}
160+
}

DeferJS.php

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
class DeferJS implements ObserverInterface
99
{
10-
1110
public function __construct(
1211
protected Config $config
1312
) {
@@ -17,9 +16,6 @@ public function execute(\Magento\Framework\Event\Observer $observer)
1716
{
1817
$removeAdobeJSJunk = boolval($this->config->getValue('react_vue_config/junk/remove'));
1918

20-
if ($removeAdobeJSJunk) {
21-
return;
22-
}
2319
$response = $observer->getEvent()->getData('response');
2420
if (!$response) {
2521
return;
@@ -28,11 +24,39 @@ public function execute(\Magento\Framework\Event\Observer $observer)
2824
if ($html == '') {
2925
return;
3026
}
31-
$conditionalJsPattern = '@(?:<script type="text/javascript"|<script)(.*)</script>@msU';
32-
preg_match_all($conditionalJsPattern, $html, $_matches);
33-
$jsHtml = implode('', $_matches[0]);
34-
$html = preg_replace($conditionalJsPattern, '', $html);
35-
$html .= $jsHtml;
27+
28+
if ($removeAdobeJSJunk) {
29+
$response->setBody($html);
30+
return;
31+
}
32+
33+
// Check if defer JS is enabled (config or GET parameter)
34+
$deferJS = $this->shouldDeferJS();
35+
if ($deferJS) {
36+
// Move scripts to bottom, but preserve scripts with no-defer attribute
37+
$conditionalJsPattern = '@(?:<script type="text/javascript"|<script)(?![^>]*no-defer)(.*)</script>@msU';
38+
preg_match_all($conditionalJsPattern, $html, $_matches);
39+
$jsHtml = implode('', $_matches[0]);
40+
$html = preg_replace($conditionalJsPattern, '', $html);
41+
$html .= $jsHtml;
42+
}
43+
3644
$response->setBody($html);
3745
}
46+
47+
private function shouldDeferJS(): bool
48+
{
49+
// Check GET parameter first
50+
if (isset($_GET['defer-js']) && $_GET['defer-js'] === "false") {
51+
return false;
52+
}
53+
if (isset($_GET['defer-js']) && $_GET['defer-js'] === "true") {
54+
return true;
55+
}
56+
57+
// Fall back to config (default to true if not set)
58+
$configValue = $this->config->getValue('react_vue_config/junk/defer_js');
59+
return $configValue === null || $configValue === '' ? true : boolval($configValue);
60+
}
61+
3862
}

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ The module supports store-specific CSS files for multi-store setups. By default,
7171

7272
The module automatically detects the current store code and loads the appropriate CSS files. If store-specific files don't exist, it falls back to the default store files. This allows you to customize styles per store while maintaining a fallback mechanism.
7373

74-
7574
# VueJS support
7675

7776
![Logo-Vuejs](https://user-images.githubusercontent.com/9213670/150036919-3486e016-3d37-4ffd-b4ee-a3a3bbc961e9.png)

Template.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ public function removeAdobeCSSJunk()
8282
return boolval($this->config->getValue('react_vue_config/junk/remove'));
8383
}
8484

85+
public function deferJS()
86+
{
87+
// Check GET parameter first
88+
if (isset($_GET['defer-js']) && $_GET['defer-js'] === "false") {
89+
return false;
90+
}
91+
if (isset($_GET['defer-js']) && $_GET['defer-js'] === "true") {
92+
return true;
93+
}
94+
95+
// Fall back to config (default to true if not set)
96+
$configValue = $this->config->getValue('react_vue_config/junk/defer_js');
97+
return $configValue === null || $configValue === '' ? true : boolval($configValue);
98+
}
99+
85100
public function getInlineJs($file) {
86101
$jsContent = file_get_contents(__DIR__ . '/view/frontend/web/js/' . $file);
87102
return '<script>' . $jsContent . '</script>';

css-purge-tests/README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# CSS Purge Tests
2+
3+
This directory contains Jest tests for `css-purge.js`.
4+
5+
## Setup
6+
7+
Install dependencies:
8+
```bash
9+
npm install
10+
```
11+
12+
## Running Tests
13+
14+
Run all tests:
15+
```bash
16+
npm test
17+
```
18+
19+
Run tests in watch mode:
20+
```bash
21+
npm run test:watch
22+
```
23+
24+
Run tests with coverage:
25+
```bash
26+
npm run test:coverage
27+
```
28+
29+
## Test Coverage
30+
31+
The tests cover the following functions from `css-purge.js`:
32+
33+
- `formatFileSize()` - File size formatting (Bytes, KB, MB, GB)
34+
- `getFileSize()` - Get file size from filesystem
35+
- `countSelectors()` - Count CSS selectors and at-rules
36+
- `applyIgnorePatterns()` - Remove CSS rules matching ignore patterns
37+
- `applyBlocklistPatterns()` - Remove CSS rules matching blocklist patterns
38+
- `loadPurgeConfig()` - Load configuration from JSON file
39+
- `getCSSFileConfig()` - Get CSS file-specific configuration
40+
41+
## Issues Fixed
42+
43+
During testing, the following issue was identified and fixed:
44+
45+
- **formatFileSize function**: Updated to always show one decimal place (e.g., "500.0 Bytes" instead of "500 Bytes") for consistency.
46+
47+
## Test Structure
48+
49+
- **Unit Tests**: Test individual helper functions in isolation
50+
- **Integration Tests**: Test functions working together with real-world scenarios
51+
52+
## Notes
53+
54+
- Tests use ES modules (type: "module" in package.json)
55+
- Jest requires `NODE_OPTIONS=--experimental-vm-modules` flag for ES module support
56+
- Test files are automatically cleaned up after each test run

0 commit comments

Comments
 (0)