diff --git a/Plugin/Framework/App/Response/Http.php b/Plugin/Framework/App/Response/Http.php new file mode 100644 index 0000000..6d8b694 --- /dev/null +++ b/Plugin/Framework/App/Response/Http.php @@ -0,0 +1,161 @@ +config->getType() !== Config::BUILT_IN || !$this->isEnabled()) { + return; + } + + $cacheControlHeader = $subject->getHeader('Cache-Control'); + if (!$cacheControlHeader) { + return; + } + + $cacheControl = $cacheControlHeader->getFieldValue(); + $requestURI = ltrim($this->request->getRequestURI(), '/'); + + if ($this->isRequestCacheable($cacheControl) && !$this->isRequestInExcludePatterns($requestURI)) { + $this->isRequestCacheable = true; + } + } + + /** + * Update cache headers after setting no-cache headers + * + * @param \Magento\Framework\App\Response\Http $subject + * @param mixed $result + * @return mixed + */ + public function afterSetNoCacheHeaders(\Magento\Framework\App\Response\Http $subject, $result) + { + if ($this->config->getType() !== Config::BUILT_IN || !$this->isEnabled()) { + return $result; + } + + $cacheControlHeader = $subject->getHeader('Cache-Control'); + if (!$cacheControlHeader) { + return $result; + } + + if ($this->isRequestCacheable === true) { + $cacheControlHeader = $subject->getHeader('Cache-Control'); + $cacheControlHeader->removeDirective('no-store'); + } + $this->isRequestCacheable = false; + + return $result; + } + + /** + * Check if request is cacheable based on cache control header + * + * @param string $cacheControl + * @return bool + */ + private function isRequestCacheable(string $cacheControl): bool + { + return (bool) preg_match('/public.*s-maxage=(\d+)/', $cacheControl); + } + + /** + * Check if the request URI contains any excluded URL patterns (case-insensitive, partial match). + * + * @param string $requestURI + * @return bool + */ + private function isRequestInExcludePatterns(string $requestURI): bool + { + $patterns = $this->getConfig(self::XML_PATH_EXCLUDE_URL_PATTERNS); + + if (empty($patterns)) { + return false; + } + + foreach ($this->parseExcludePatterns($patterns) as $pattern) { + if ($pattern !== '' && mb_stripos($requestURI, $pattern, 0, 'UTF-8') !== false) { + return true; + } + } + + return false; + } + + /** + * Parse exclude patterns from config string. + * + * @param string $patterns + * @return array + */ + private function parseExcludePatterns(string $patterns): array + { + return array_filter(array_map('trim', explode("\n", $patterns))); + } + + /** + * Check if BFCache is enabled + * + * @return bool + */ + private function isEnabled(): bool + { + return $this->scopeConfig->isSetFlag( + self::XML_PATH_ENABLE, + ScopeInterface::SCOPE_STORE + ); + } + + /** + * Get configuration value by path + * + * @param string $configPath + * @param int|string|null $store + * @return string + */ + private function getConfig(string $configPath, $store = null): string + { + return (string)$this->scopeConfig->getValue( + $configPath, + ScopeInterface::SCOPE_STORE, + $store + ); + } +} diff --git a/ViewModel/BfCache.php b/ViewModel/BfCache.php new file mode 100644 index 0000000..53f1a55 --- /dev/null +++ b/ViewModel/BfCache.php @@ -0,0 +1,71 @@ +scopeConfig->isSetFlag( + self::XML_PATH_ENABLE_USER_INTERACTION_RELOAD_MINICART, + ScopeInterface::SCOPE_STORE + ); + } + + /** + * Check if mobile menu should auto-close + * + * @return bool + */ + public function autoCloseMenuMobile(): bool + { + return $this->scopeConfig->isSetFlag( + self::XML_PATH_AUTO_CLOSE_MENU_MOBILE, + ScopeInterface::SCOPE_STORE + ); + } + + /** + * Check if customer is logged in + * + * @return bool + */ + public function isCustomerLoggedIn(): bool + { + return (bool) $this->httpContext->getValue(CustomerContext::CONTEXT_AUTH); + } +} diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 974bf87..83f8ce5 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -51,5 +51,61 @@ +
+ + + Go back and forwards in the browser history on most pages instantly, by allowing them to be cached in the browser temporarily.

+ ]]>
+ + + 1 + + + Magento\Config\Model\Config\Source\Yesno + + + + + + Magento\Config\Model\Config\Source\Yesno + Yes: Mini cart updates only after user interaction when page is restored from cache.
+ No: Mini cart updates immediately on page restore.
+ Recommended "Yes" to maintain optimal Page Speed and Core Web Vitals scores. + ]]> +
+
+ + + Magento\Config\Model\Config\Source\Yesno + + + +
+ + + Note: This configuration is optional for most sites.
+ The extension automatically excludes non-cacheable URLs from back/forward cache.

+

Use the blacklist below if you have custom cached URLs that load private data via JavaScript.

+ ]]>
+ + + + Example:
+ customer
+ checkout
+ account + ]]>
+
+
+
+
diff --git a/etc/config.xml b/etc/config.xml index 3dc8215..590fa93 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -21,5 +21,14 @@ productalert .do-not-prerender + + + + 1 + 1 + 1 + + + diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml new file mode 100644 index 0000000..b86d0f1 --- /dev/null +++ b/etc/frontend/di.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/etc/module.xml b/etc/module.xml index 2dd2d99..761ac74 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -4,6 +4,8 @@ + + diff --git a/view/frontend/layout/default.xml b/view/frontend/layout/default.xml index 56a29d7..a494de5 100644 --- a/view/frontend/layout/default.xml +++ b/view/frontend/layout/default.xml @@ -10,6 +10,13 @@ \MageOS\ThemeOptimization\ViewModel\SpeculationRules + + + \MageOS\ThemeOptimization\ViewModel\BfCache + + diff --git a/view/frontend/layout/default_hyva.xml b/view/frontend/layout/default_hyva.xml new file mode 100644 index 0000000..1467ee0 --- /dev/null +++ b/view/frontend/layout/default_hyva.xml @@ -0,0 +1,14 @@ + + + + + + + \MageOS\ThemeOptimization\ViewModel\BfCache + + + + + diff --git a/view/frontend/templates/bfcache/handler.phtml b/view/frontend/templates/bfcache/handler.phtml new file mode 100644 index 0000000..a6ffe5b --- /dev/null +++ b/view/frontend/templates/bfcache/handler.phtml @@ -0,0 +1,161 @@ +getData('bfcache_config'); +$isCustomerLoggedIn = $bfcacheConfig->isCustomerLoggedIn() ? 'true' : 'false'; +$enableUserInteractionRefresh = $bfcacheConfig->isReloadMiniCartOnInteraction() ? 'true' : 'false'; +$autoCloseMenuMobile = $bfcacheConfig->autoCloseMenuMobile() ? 'true' : 'false'; + +$script = << { + 'use strict'; + + class BFCacheHandler { + /** + * Initialize BFCache handler with configuration options + */ + constructor() { + this.options = { + isCustomerLoggedIn: {$isCustomerLoggedIn}, + enableUserInteractionRefreshMiniCart: {$enableUserInteractionRefresh}, + autoCloseMenuMobile: {$autoCloseMenuMobile} + }; + + this.userInteractionEvents = ['touchstart', 'mouseover', 'wheel', 'scroll', 'keydown']; + } + + /** + * Initialize all BFCache functionalities + */ + init() { + this.refreshMiniCart(); + this.reloadCustomerLoginPage(); + this.actionAutoCloseMenu(this.options.autoCloseMenuMobile); + } + + /** + * Refresh minicart based on configuration + * Either immediately or on first user interaction + */ + refreshMiniCart() { + if (this.options.enableUserInteractionRefreshMiniCart) { + this.refreshMiniCartOnUserInteraction(); + } else { + this.actionRefreshMiniCart(); + } + } + + /** + * Refresh minicart on first user interaction + * Removes event listeners after first trigger to avoid multiple calls + */ + refreshMiniCartOnUserInteraction() { + const refreshMiniCart = () => { + this.userInteractionEvents.forEach(eventType => { + window.removeEventListener(eventType, refreshMiniCart); + }); + this.actionRefreshMiniCart(); + }; + + this.userInteractionEvents.forEach(eventType => { + window.addEventListener(eventType, refreshMiniCart, { + once: true, + passive: true + }); + }); + } + + /** + * Check customer login state consistency and reload if needed + * Compares backend state with frontend localStorage state + */ + reloadCustomerLoginPage() { + const backendLoggedInState = this.options.isCustomerLoggedIn; + + const getCustomerDataFromStorage = () => { + try { + const cacheStorage = localStorage.getItem('mage-cache-storage'); + const customerData = cacheStorage ? JSON.parse(cacheStorage).customer : null; + return customerData; + } catch (error) { + console.warn('BFCache: Failed to parse customer data from localStorage', error); + return null; + } + }; + + const customerData = getCustomerDataFromStorage(); + const frontendLoggedInState = Boolean(customerData?.firstname); + + if (frontendLoggedInState !== backendLoggedInState) { + window.location.reload(); + } + } + + /** + * Update minicart data from customer data sections + * Reloads cart section to ensure accurate item count and totals + */ + actionRefreshMiniCart() { + require([ + 'Magento_Customer/js/customer-data' + ], function (customerData) { + customerData.reload(['cart'], true); + }); + } + + /** + * Handles cart drawer menu and mobile menu state management + * + * @param {boolean} autoCloseMenuMobile - Whether to auto-close mobile menu + */ + actionAutoCloseMenu(autoCloseMenuMobile) { + require([ + 'jquery', + 'Magento_Customer/js/customer-data' + ], function ($, customerData) { + customerData.reload(['messages'], true); + + const minicartCloseButton = $('#btn-minicart-close'); + if (minicartCloseButton.length) { + minicartCloseButton.trigger('click'); + } + + if (autoCloseMenuMobile) { + const htmlElement = $('html'); + + const navigationClasses = ['nav-open', 'nav-before-open']; + navigationClasses.forEach(function(className) { + if (htmlElement.hasClass(className)) { + htmlElement.removeClass(className); + } + }); + } + }); + } + }; + + /** + * Handle pageshow event for BFCache scenarios + * Detects back/forward navigation and page restoration from BFCache + */ + const handlePageShow = (event) => { + if (event.persisted) { + new BFCacheHandler().init(); + } + }; + + // Register event listener with proper cleanup capability + window.addEventListener('pageshow', handlePageShow, { passive: true }); + })(); +JS; +?> +renderTag('script', ['type' => 'text/javascript'], $script, false) ?> diff --git a/view/frontend/templates/hyva/bfcache/handler.phtml b/view/frontend/templates/hyva/bfcache/handler.phtml new file mode 100644 index 0000000..96acc5b --- /dev/null +++ b/view/frontend/templates/hyva/bfcache/handler.phtml @@ -0,0 +1,172 @@ +getData('bfcache_config'); +$isCustomerLoggedIn = $bfcacheConfig->isCustomerLoggedIn() ? 'true' : 'false'; +$enableUserInteractionRefresh = $bfcacheConfig->isReloadMiniCartOnInteraction() ? 'true' : 'false'; +$autoCloseMenuMobile = $bfcacheConfig->autoCloseMenuMobile() ? 'true' : 'false'; + +$script = << { + 'use strict'; + + class BFCacheHandler { + /** + * Initialize BFCache handler with configuration options + */ + constructor() { + this.options = { + isCustomerLoggedIn: {$isCustomerLoggedIn}, + enableUserInteractionRefreshMiniCart: {$enableUserInteractionRefresh}, + autoCloseMenuMobile: {$autoCloseMenuMobile} + }; + + this.userInteractionEvents = ['touchstart', 'mouseover', 'wheel', 'scroll', 'keydown']; + } + + /** + * Initialize all BFCache functionalities + */ + init() { + this.refreshMiniCart(); + this.reloadCustomerLoginPage(); + this.actionAutoCloseMenu(this.options.autoCloseMenuMobile); + } + + /** + * Refresh minicart based on configuration + * Either immediately or on first user interaction + */ + refreshMiniCart() { + if (this.options.enableUserInteractionRefreshMiniCart) { + this.refreshMiniCartOnUserInteraction(); + } else { + this.actionRefreshMiniCart(); + } + } + + /** + * Refresh minicart on first user interaction + * Removes event listeners after first trigger to avoid multiple calls + */ + refreshMiniCartOnUserInteraction() { + const refreshMiniCart = () => { + this.userInteractionEvents.forEach(eventType => { + window.removeEventListener(eventType, refreshMiniCart); + }); + this.actionRefreshMiniCart(); + }; + + this.userInteractionEvents.forEach(eventType => { + window.addEventListener(eventType, refreshMiniCart, { + once: true, + passive: true + }); + }); + } + + /** + * Check customer login state consistency and reload if needed + * Compares backend state with frontend localStorage state + */ + reloadCustomerLoginPage() { + const backendLoggedInState = this.options.isCustomerLoggedIn; + + const getCustomerDataFromStorage = () => { + try { + const cacheStorage = localStorage.getItem('mage-cache-storage'); + const customerData = cacheStorage ? JSON.parse(cacheStorage).customer : null; + return customerData; + } catch (error) { + console.warn('BFCache: Failed to parse customer data from localStorage', error); + return null; + } + }; + + const customerData = getCustomerDataFromStorage(); + const frontendLoggedInState = Boolean(customerData?.firstname); + + if (frontendLoggedInState !== backendLoggedInState) { + window.location.reload(); + } + } + + /** + * Update minicart data from customer data sections + * Reloads cart section to ensure accurate item count and totals + */ + actionRefreshMiniCart() { + window.dispatchEvent(new CustomEvent('reload-customer-section-data')); + } + + /** + * Handles cart drawer menu and mobile menu state management + * + * @param {boolean} autoCloseMenuMobile - Whether to auto-close mobile menu + */ + actionAutoCloseMenu(autoCloseMenuMobile) { + window.dispatchEvent(new Event('clear-messages')); + + const cartDrawer = document.querySelector("[x-data^='initCartDrawer']"); + if (!cartDrawer || typeof Alpine === 'undefined') { + return; + } + const cartDrawerData = Alpine.\$data(cartDrawer); + if (cartDrawerData && typeof cartDrawerData.open !== 'undefined') { + cartDrawerData.open = false; + document.body.style.overflow = ''; + } + + if (autoCloseMenuMobile) { + this.handleMenuClosure(); + } + } + + /** + * Close mobile and desktop menus + * Uses Alpine.js data binding to manage menu states + */ + handleMenuClosure() { + const mobileMenu = document.querySelector("[x-data^='initMenuMobile']"); + if (mobileMenu && typeof Alpine !== 'undefined') { + const mobileMenuData = Alpine.\$data(mobileMenu); + if (mobileMenuData && typeof mobileMenuData.open !== 'undefined') { + mobileMenuData.open = false; + } + } + + const desktopMenu = document.querySelector("[x-data^='initMenuDesktop']"); + if (desktopMenu && typeof Alpine !== 'undefined') { + const desktopMenuData = Alpine.\$data(desktopMenu); + if (desktopMenuData && typeof desktopMenuData.hoverPanelActiveId !== 'undefined') { + desktopMenuData.hoverPanelActiveId = 0; + } + } + } + }; + + /** + * Handle pageshow event for BFCache scenarios + * Detects back/forward navigation and page restoration from BFCache + */ + const handlePageShow = (event) => { + if (event.persisted) { + new BFCacheHandler().init(); + } + }; + + // Register event listener with proper cleanup capability + window.addEventListener('pageshow', handlePageShow, { passive: true }); + })(); +JS; +?> +renderTag('script', ['type' => 'text/javascript'], $script, false) ?>