From 438133e69d8b530ce765c08b285f0aece446da2c Mon Sep 17 00:00:00 2001 From: Samuele Martini Date: Tue, 21 Apr 2026 19:02:42 +0200 Subject: [PATCH 1/4] Add PHP 8.4 and PHP 8.5 compatibility --- CHANGELOG.md | 20 ++++ Plugin/Framework/App/Response/Http.php | 66 +++-------- .../UpdateSpeculationRulesConfigPathPatch.php | 49 ++------ ViewModel/BfCache.php | 29 +---- ViewModel/SpeculationRules.php | 110 ++++++++++-------- ViewModel/ViewTransitions.php | 23 +++- composer.json | 1 + 7 files changed, 136 insertions(+), 162 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bcb7581 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## [2.3.0] - 2026-04-21 + +### Added +- PHP 8.4 and PHP 8.5 compatibility. +- Explicit `php` constraint in `composer.json` (`~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0`). + +### Changed +- Added `declare(strict_types=1)` to all PHP classes. +- Added explicit return types on all methods (including `Setup\Patch\Data\UpdateSpeculationRulesConfigPathPatch::apply()`, `getAliases()` and `getDependencies()`). +- Typed class constants (`const string`, `const array`) to take advantage of PHP 8.3+ typed constants. +- Replaced `strpos($s, $needle) === 0` with `str_starts_with()` for clarity. +- Typed nullable parameters explicitly (e.g. `int|string|null $store`) to avoid the PHP 8.4 implicit nullable deprecation. +- Made constructor-promoted properties and helper methods `protected` instead of `private` for easier extension by downstream modules. +- Minor refactors (`array_map` in place of manual loops, `unset` of loop reference variable). + +### Notes +- No public API change: all previously `public` methods keep the same signatures. +- Extensions that relied on subclassing internals now benefit from `protected` visibility. diff --git a/Plugin/Framework/App/Response/Http.php b/Plugin/Framework/App/Response/Http.php index 8515eb5..3549b32 100644 --- a/Plugin/Framework/App/Response/Http.php +++ b/Plugin/Framework/App/Response/Http.php @@ -4,42 +4,29 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Framework\App\Response\Http as ResponseHttp; use Magento\PageCache\Model\Config; use Magento\Store\Model\ScopeInterface; -/** - * Plugin to modify cache headers for BFCache functionality - */ class Http { - /** @var string */ - public const XML_PATH_ENABLE = 'system/bfcache/general/enable'; + public const string XML_PATH_ENABLE = 'system/bfcache/general/enable'; + public const string XML_PATH_EXCLUDE_URL_PATTERNS = 'system/bfcache/scope/exclude_url_patterns'; - /** @var string */ - public const XML_PATH_EXCLUDE_URL_PATTERNS = 'system/bfcache/scope/exclude_url_patterns'; + protected bool $isRequestCacheable = false; - /** @var bool */ - private $isRequestCacheable = false; - - /** - * @param Config $config - * @param ScopeConfigInterface $scopeConfig - * @param HttpRequest $request - */ public function __construct( - private Config $config, - private ScopeConfigInterface $scopeConfig, - private HttpRequest $request + protected Config $config, + protected ScopeConfigInterface $scopeConfig, + protected HttpRequest $request ) { } /** - * Intercept before setting no-cache headers to determine if request is cacheable - * - * @param \Magento\Framework\App\Response\Http $subject + * @param ResponseHttp $subject * @return void */ - public function beforeSetNoCacheHeaders(\Magento\Framework\App\Response\Http $subject): void + public function beforeSetNoCacheHeaders(ResponseHttp $subject): void { if ($this->config->getType() !== Config::BUILT_IN || !$this->isEnabled()) { return; @@ -59,13 +46,11 @@ public function beforeSetNoCacheHeaders(\Magento\Framework\App\Response\Http $su } /** - * Update cache headers after setting no-cache headers - * - * @param \Magento\Framework\App\Response\Http $subject + * @param ResponseHttp $subject * @param mixed $result * @return mixed */ - public function afterSetNoCacheHeaders(\Magento\Framework\App\Response\Http $subject, $result) + public function afterSetNoCacheHeaders(ResponseHttp $subject, mixed $result): mixed { if ($this->config->getType() !== Config::BUILT_IN || !$this->isEnabled()) { return $result; @@ -77,7 +62,6 @@ public function afterSetNoCacheHeaders(\Magento\Framework\App\Response\Http $sub } if ($this->isRequestCacheable === true) { - $cacheControlHeader = $subject->getHeader('Cache-Control'); $cacheControlHeader->removeDirective('no-store'); } $this->isRequestCacheable = false; @@ -86,35 +70,29 @@ public function afterSetNoCacheHeaders(\Magento\Framework\App\Response\Http $sub } /** - * Check if request is cacheable based on cache control header - * * @param string $cacheControl * @return bool */ - private function isRequestCacheable(string $cacheControl): bool + protected function isRequestCacheable(string $cacheControl): bool { - // FPC hits will not have public or private cache control directives -- already processed if (!str_contains($cacheControl, 'public') && !str_contains($cacheControl, 'private') && !str_contains($cacheControl, 'no-store')) { return true; } - // FPC misses will be cacheable if they have a public directive - return (bool) preg_match('/public.*s-maxage=(\d+)/', $cacheControl); + 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 + protected function isRequestInExcludePatterns(string $requestURI): bool { $patterns = $this->getConfig(self::XML_PATH_EXCLUDE_URL_PATTERNS); - if (empty($patterns)) { + if ($patterns === '') { return false; } @@ -128,22 +106,18 @@ private function isRequestInExcludePatterns(string $requestURI): bool } /** - * Parse exclude patterns from config string. - * * @param string $patterns * @return array */ - private function parseExcludePatterns(string $patterns): array + protected function parseExcludePatterns(string $patterns): array { - return array_filter(array_map('trim', explode("\n", $patterns))); + return array_values(array_filter(array_map('trim', explode("\n", $patterns)))); } /** - * Check if BFCache is enabled - * * @return bool */ - private function isEnabled(): bool + protected function isEnabled(): bool { return $this->scopeConfig->isSetFlag( self::XML_PATH_ENABLE, @@ -152,13 +126,11 @@ private function isEnabled(): bool } /** - * Get configuration value by path - * * @param string $configPath * @param int|string|null $store * @return string */ - private function getConfig(string $configPath, $store = null): string + protected function getConfig(string $configPath, int|string|null $store = null): string { return (string)$this->scopeConfig->getValue( $configPath, diff --git a/Setup/Patch/Data/UpdateSpeculationRulesConfigPathPatch.php b/Setup/Patch/Data/UpdateSpeculationRulesConfigPathPatch.php index f1bf0a7..77ce393 100644 --- a/Setup/Patch/Data/UpdateSpeculationRulesConfigPathPatch.php +++ b/Setup/Patch/Data/UpdateSpeculationRulesConfigPathPatch.php @@ -1,4 +1,4 @@ -moduleDataSetup = $moduleDataSetup; + protected ModuleDataSetupInterface $moduleDataSetup + ) { } /** - * Do Upgrade. - * - * @return void + * @return self */ - public function apply() + public function apply(): self { - $this->moduleDataSetup->getConnection()->startSetup(); - - // Change the core_config_data path for any 'dev/speculation_rules/*' values to 'system/speculation_rules/*' $connection = $this->moduleDataSetup->getConnection(); + $connection->startSetup(); + $connection->update( $this->moduleDataSetup->getTable('core_config_data'), [ @@ -47,32 +31,23 @@ public function apply() ['path LIKE ?' => 'dev/speculation_rules/%'] ); - $this->moduleDataSetup->getConnection()->endSetup(); + $connection->endSetup(); + + return $this; } /** - * Get aliases (previous names) for the patch. - * * @return string[] */ - public function getAliases() + public function getAliases(): array { return []; } /** - * Get array of patches that have to be executed prior to this. - * - * Example of implementation: - * - * [ - * \Vendor_Name\Module_Name\Setup\Patch\Patch1::class, - * \Vendor_Name\Module_Name\Setup\Patch\Patch2::class - * ] - * * @return string[] */ - public static function getDependencies() + public static function getDependencies(): array { return []; } diff --git a/ViewModel/BfCache.php b/ViewModel/BfCache.php index 53f1a55..2d4e949 100644 --- a/ViewModel/BfCache.php +++ b/ViewModel/BfCache.php @@ -8,34 +8,21 @@ use Magento\Framework\View\Element\Block\ArgumentInterface; use Magento\Store\Model\ScopeInterface; -/** - * BFCache ViewModel - * Provides data and configuration for BFCache templates. - * Handles configuration management and business logic for frontend templates. - */ class BfCache implements ArgumentInterface { - /** @var string */ - private const XML_PATH_ENABLE_USER_INTERACTION_RELOAD_MINICART = + protected const string XML_PATH_ENABLE_USER_INTERACTION_RELOAD_MINICART = 'system/bfcache/general/enable_user_interaction_reload_minicart'; - - /** @var string */ - private const XML_PATH_AUTO_CLOSE_MENU_MOBILE = + + protected const string XML_PATH_AUTO_CLOSE_MENU_MOBILE = 'system/bfcache/general/auto_close_menu_mobile'; - /** - * @param ScopeConfigInterface $scopeConfig - * @param Context $httpContext - */ public function __construct( - private ScopeConfigInterface $scopeConfig, - private Context $httpContext + protected ScopeConfigInterface $scopeConfig, + protected Context $httpContext ) { } /** - * Check if mini cart should reload on user interaction - * * @return bool */ public function isReloadMiniCartOnInteraction(): bool @@ -47,8 +34,6 @@ public function isReloadMiniCartOnInteraction(): bool } /** - * Check if mobile menu should auto-close - * * @return bool */ public function autoCloseMenuMobile(): bool @@ -60,12 +45,10 @@ public function autoCloseMenuMobile(): bool } /** - * Check if customer is logged in - * * @return bool */ public function isCustomerLoggedIn(): bool { - return (bool) $this->httpContext->getValue(CustomerContext::CONTEXT_AUTH); + return (bool)$this->httpContext->getValue(CustomerContext::CONTEXT_AUTH); } } diff --git a/ViewModel/SpeculationRules.php b/ViewModel/SpeculationRules.php index 29567da..9000ae6 100644 --- a/ViewModel/SpeculationRules.php +++ b/ViewModel/SpeculationRules.php @@ -1,36 +1,43 @@ -getConfigValue('enable'); } + /** + * @return string + */ public function getMode(): string { $mode = $this->getConfigValue('mode'); @@ -42,6 +49,9 @@ public function getMode(): string return self::MODE_PREFETCH; } + /** + * @return string + */ public function getEagerness(): string { $eagerness = $this->getConfigValue('eagerness'); @@ -50,12 +60,10 @@ public function getEagerness(): string return $eagerness; } - return 'moderate'; + return self::EAGERNESS_DEFAULT; } /** - * Check if the current mode is prerender - * * @return bool */ public function isPrerenderMode(): bool @@ -64,10 +72,6 @@ public function isPrerenderMode(): bool } /** - * Get prerendering change script for customer data reinitialization - * Returns script only when prerender mode is enabled - * Uses different approaches for Hyva vs Luma themes - * * @return string */ public function getPrerenderingScript(): string @@ -76,7 +80,6 @@ public function getPrerenderingScript(): string return ''; } - // Hyva theme uses a custom event, Luma uses RequireJS $reloadAction = $this->isHyva() ? "window.dispatchEvent(new CustomEvent('reload-customer-section-data'));" : "require(['Magento_Customer/js/customer-data'], customerData => { @@ -94,9 +97,11 @@ public function getPrerenderingScript(): string JS; } + /** + * @return array + */ public function getSpeculationRules(): array { - // Possible future development: add support for multiple modes and rulesets at once. return [ $this->getMode() => [ [ @@ -108,34 +113,31 @@ public function getSpeculationRules(): array ]; } + /** + * @return string + * @throws InvalidArgumentException + */ public function getSpeculationRulesJson(): string { - $rules = $this->getSpeculationRules(); - - return $this->serializer->serialize($rules); + return $this->serializer->serialize($this->getSpeculationRules()); } + /** + * @return array + */ protected function buildRules(): array { - // Include all URLs by default $rules = [ 'and' => [ ['href_matches' => '/*'] ], ]; - // Exclude path patterns (wildcards) $rules['and'][] = $this->getExcludedPaths(); - // Exclude file extensions array_push($rules['and'], ...$this->getExcludedExtensions()); - - // Exclude selectors array_push($rules['and'], ...$this->getExcludedSelectors()); - // TODO: Add extensibility? - - // Always exclude common unsafe targets $rules['and'][] = ['not' => ['selector_matches' => '[rel=nofollow]']]; $rules['and'][] = ['not' => ['selector_matches' => '[target=_blank]']]; $rules['and'][] = ['not' => ['selector_matches' => '[target=_parent]']]; @@ -144,14 +146,23 @@ protected function buildRules(): array return $rules; } + /** + * @param string $key + * @return string|null + */ protected function getConfigValue(string $key): ?string { - return $this->scopeConfig->getValue( + $value = $this->scopeConfig->getValue( self::CONFIG_PATH . $key, ScopeInterface::SCOPE_STORE ); + + return $value === null ? null : (string)$value; } + /** + * @return array + */ public function getExcludedPaths(): array { $paths = explode("\n", (string)$this->getConfigValue('exclude_paths')); @@ -159,6 +170,7 @@ public function getExcludedPaths(): array foreach ($paths as &$pattern) { $pattern = trim(trim($pattern), '/'); } + unset($pattern); $paths = array_filter($paths); if (empty($paths)) { @@ -168,42 +180,42 @@ public function getExcludedPaths(): array return ['not' => ['href_matches' => '/*(' . implode('|', $paths) . ')/*']]; } + /** + * @return array + */ public function getExcludedExtensions(): array { - $rules = []; - $extensions = explode(',', (string)$this->getConfigValue('exclude_extensions')); $extensions = array_filter(array_map('trim', $extensions)); - foreach ($extensions as $extension) { - $rules[] = ['not' => ['href_matches' => sprintf('*.%s', ltrim($extension, '.'))]]; - } - return $rules; + return array_map( + static fn(string $extension): array => ['not' => ['href_matches' => sprintf('*.%s', ltrim($extension, '.'))]], + array_values($extensions) + ); } + /** + * @return array + */ public function getExcludedSelectors(): array { - $rules = []; - $selectors = explode("\n", (string)$this->getConfigValue('exclude_selectors')); $selectors = array_filter(array_map('trim', $selectors)); - foreach ($selectors as $selector) { - $rules[] = ['not' => ['selector_matches' => $selector]]; - } - return $rules; + return array_map( + static fn(string $selector): array => ['not' => ['selector_matches' => $selector]], + array_values($selectors) + ); } /** - * Check if current theme is Hyva or extends from Hyva - * * @return bool */ - private function isHyva(): bool + protected function isHyva(): bool { $theme = $this->viewDesign->getDesignTheme(); while ($theme) { - if (strpos($theme->getCode(), 'Hyva/') === 0) { + if (str_starts_with((string)$theme->getCode(), 'Hyva/')) { return true; } $theme = $theme->getParentTheme(); diff --git a/ViewModel/ViewTransitions.php b/ViewModel/ViewTransitions.php index fd65974..434eaa9 100644 --- a/ViewModel/ViewTransitions.php +++ b/ViewModel/ViewTransitions.php @@ -1,4 +1,4 @@ -scopeConfig->getValue( + $value = $this->scopeConfig->getValue( self::CONFIG_PATH . $key, ScopeInterface::SCOPE_STORE ); + + return $value === null ? null : (string)$value; } + /** + * @return bool + */ public function isEnabled(): bool { return (bool)$this->getConfigValue('enable'); } + /** + * @return bool + */ public function isEnabledForBfcache(): bool { return (bool)$this->getConfigValue('enable_for_bfcache'); diff --git a/composer.json b/composer.json index 1446bde..57a5a55 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,7 @@ "description": "Page transitions and speculative loading rules for Magento", "type": "magento2-module", "require": { + "php": "~8.1.0||~8.2.0||~8.3.0||~8.4.0||~8.5.0", "magento/framework": "^103.0", "magento/module-store": "*" }, From 436709f0eb6f287ace7bef0f664fe90a01ae4417 Mon Sep 17 00:00:00 2001 From: Ryan Hoerr Date: Mon, 11 May 2026 21:12:57 -0400 Subject: [PATCH 2/4] Address review feedback - Drop typed class constants (`const string`/`const array`) for PHP 8.1/8.2 compatibility per composer.json constraint. - Restore docblock and inline comments removed across Http, BfCache, SpeculationRules, and UpdateSpeculationRulesConfigPathPatch. - Revert the array_map refactors in SpeculationRules::getExcludedExtensions() and getExcludedSelectors() to the original foreach style for readability. - Update CHANGELOG to drop the typed-const note. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 +- Plugin/Framework/App/Response/Http.php | 23 ++++++++- .../UpdateSpeculationRulesConfigPathPatch.php | 17 +++++++ ViewModel/BfCache.php | 15 +++++- ViewModel/SpeculationRules.php | 50 +++++++++++++------ ViewModel/ViewTransitions.php | 2 +- 6 files changed, 89 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb7581..8620668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,10 @@ ### Changed - Added `declare(strict_types=1)` to all PHP classes. - Added explicit return types on all methods (including `Setup\Patch\Data\UpdateSpeculationRulesConfigPathPatch::apply()`, `getAliases()` and `getDependencies()`). -- Typed class constants (`const string`, `const array`) to take advantage of PHP 8.3+ typed constants. - Replaced `strpos($s, $needle) === 0` with `str_starts_with()` for clarity. - Typed nullable parameters explicitly (e.g. `int|string|null $store`) to avoid the PHP 8.4 implicit nullable deprecation. - Made constructor-promoted properties and helper methods `protected` instead of `private` for easier extension by downstream modules. -- Minor refactors (`array_map` in place of manual loops, `unset` of loop reference variable). +- Minor refactor: `unset` of loop reference variable after foreach-by-reference. ### Notes - No public API change: all previously `public` methods keep the same signatures. diff --git a/Plugin/Framework/App/Response/Http.php b/Plugin/Framework/App/Response/Http.php index 3549b32..10acd93 100644 --- a/Plugin/Framework/App/Response/Http.php +++ b/Plugin/Framework/App/Response/Http.php @@ -8,10 +8,13 @@ use Magento\PageCache\Model\Config; use Magento\Store\Model\ScopeInterface; +/** + * Plugin to modify cache headers for BFCache functionality + */ class Http { - public const string XML_PATH_ENABLE = 'system/bfcache/general/enable'; - public const string XML_PATH_EXCLUDE_URL_PATTERNS = 'system/bfcache/scope/exclude_url_patterns'; + public const XML_PATH_ENABLE = 'system/bfcache/general/enable'; + public const XML_PATH_EXCLUDE_URL_PATTERNS = 'system/bfcache/scope/exclude_url_patterns'; protected bool $isRequestCacheable = false; @@ -23,6 +26,8 @@ public function __construct( } /** + * Intercept before setting no-cache headers to determine if request is cacheable + * * @param ResponseHttp $subject * @return void */ @@ -46,6 +51,8 @@ public function beforeSetNoCacheHeaders(ResponseHttp $subject): void } /** + * Update cache headers after setting no-cache headers + * * @param ResponseHttp $subject * @param mixed $result * @return mixed @@ -70,21 +77,27 @@ public function afterSetNoCacheHeaders(ResponseHttp $subject, mixed $result): mi } /** + * Check if request is cacheable based on cache control header + * * @param string $cacheControl * @return bool */ protected function isRequestCacheable(string $cacheControl): bool { + // FPC hits will not have public or private cache control directives -- already processed if (!str_contains($cacheControl, 'public') && !str_contains($cacheControl, 'private') && !str_contains($cacheControl, 'no-store')) { return true; } + // FPC misses will be cacheable if they have a public directive 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 */ @@ -106,6 +119,8 @@ protected function isRequestInExcludePatterns(string $requestURI): bool } /** + * Parse exclude patterns from config string. + * * @param string $patterns * @return array */ @@ -115,6 +130,8 @@ protected function parseExcludePatterns(string $patterns): array } /** + * Check if BFCache is enabled + * * @return bool */ protected function isEnabled(): bool @@ -126,6 +143,8 @@ protected function isEnabled(): bool } /** + * Get configuration value by path + * * @param string $configPath * @param int|string|null $store * @return string diff --git a/Setup/Patch/Data/UpdateSpeculationRulesConfigPathPatch.php b/Setup/Patch/Data/UpdateSpeculationRulesConfigPathPatch.php index 77ce393..e598d13 100644 --- a/Setup/Patch/Data/UpdateSpeculationRulesConfigPathPatch.php +++ b/Setup/Patch/Data/UpdateSpeculationRulesConfigPathPatch.php @@ -6,6 +6,9 @@ use Magento\Framework\Setup\Patch\DataPatchInterface; use Zend_Db_Expr; +/** + * Update config path for speculation rules from version 1.0.0 + */ class UpdateSpeculationRulesConfigPathPatch implements DataPatchInterface { public function __construct( @@ -14,6 +17,8 @@ public function __construct( } /** + * Do Upgrade. + * * @return self */ public function apply(): self @@ -21,6 +26,7 @@ public function apply(): self $connection = $this->moduleDataSetup->getConnection(); $connection->startSetup(); + // Change the core_config_data path for any 'dev/speculation_rules/*' values to 'system/speculation_rules/*' $connection->update( $this->moduleDataSetup->getTable('core_config_data'), [ @@ -37,6 +43,8 @@ public function apply(): self } /** + * Get aliases (previous names) for the patch. + * * @return string[] */ public function getAliases(): array @@ -45,6 +53,15 @@ public function getAliases(): array } /** + * Get array of patches that have to be executed prior to this. + * + * Example of implementation: + * + * [ + * \Vendor_Name\Module_Name\Setup\Patch\Patch1::class, + * \Vendor_Name\Module_Name\Setup\Patch\Patch2::class + * ] + * * @return string[] */ public static function getDependencies(): array diff --git a/ViewModel/BfCache.php b/ViewModel/BfCache.php index 2d4e949..380945d 100644 --- a/ViewModel/BfCache.php +++ b/ViewModel/BfCache.php @@ -8,12 +8,17 @@ use Magento\Framework\View\Element\Block\ArgumentInterface; use Magento\Store\Model\ScopeInterface; +/** + * BFCache ViewModel + * Provides data and configuration for BFCache templates. + * Handles configuration management and business logic for frontend templates. + */ class BfCache implements ArgumentInterface { - protected const string XML_PATH_ENABLE_USER_INTERACTION_RELOAD_MINICART = + protected const XML_PATH_ENABLE_USER_INTERACTION_RELOAD_MINICART = 'system/bfcache/general/enable_user_interaction_reload_minicart'; - protected const string XML_PATH_AUTO_CLOSE_MENU_MOBILE = + protected const XML_PATH_AUTO_CLOSE_MENU_MOBILE = 'system/bfcache/general/auto_close_menu_mobile'; public function __construct( @@ -23,6 +28,8 @@ public function __construct( } /** + * Check if mini cart should reload on user interaction + * * @return bool */ public function isReloadMiniCartOnInteraction(): bool @@ -34,6 +41,8 @@ public function isReloadMiniCartOnInteraction(): bool } /** + * Check if mobile menu should auto-close + * * @return bool */ public function autoCloseMenuMobile(): bool @@ -45,6 +54,8 @@ public function autoCloseMenuMobile(): bool } /** + * Check if customer is logged in + * * @return bool */ public function isCustomerLoggedIn(): bool diff --git a/ViewModel/SpeculationRules.php b/ViewModel/SpeculationRules.php index 9000ae6..ecaec46 100644 --- a/ViewModel/SpeculationRules.php +++ b/ViewModel/SpeculationRules.php @@ -12,12 +12,12 @@ class SpeculationRules implements ArgumentInterface { - protected const string CONFIG_PATH = 'system/speculation_rules/'; - protected const string MODE_PREFETCH = 'prefetch'; - protected const string MODE_PRERENDER = 'prerender'; - protected const string EAGERNESS_DEFAULT = 'moderate'; - protected const array FETCH_MODES = [self::MODE_PREFETCH, self::MODE_PRERENDER]; - protected const array EAGERNESS_MODES = ['conservative', 'moderate', 'eager']; + protected const CONFIG_PATH = 'system/speculation_rules/'; + protected const MODE_PREFETCH = 'prefetch'; + protected const MODE_PRERENDER = 'prerender'; + protected const EAGERNESS_DEFAULT = 'moderate'; + protected const FETCH_MODES = [self::MODE_PREFETCH, self::MODE_PRERENDER]; + protected const EAGERNESS_MODES = ['conservative', 'moderate', 'eager']; public function __construct( protected ScopeConfigInterface $scopeConfig, @@ -64,6 +64,8 @@ public function getEagerness(): string } /** + * Check if the current mode is prerender + * * @return bool */ public function isPrerenderMode(): bool @@ -72,6 +74,10 @@ public function isPrerenderMode(): bool } /** + * Get prerendering change script for customer data reinitialization + * Returns script only when prerender mode is enabled + * Uses different approaches for Hyva vs Luma themes + * * @return string */ public function getPrerenderingScript(): string @@ -80,6 +86,7 @@ public function getPrerenderingScript(): string return ''; } + // Hyva theme uses a custom event, Luma uses RequireJS $reloadAction = $this->isHyva() ? "window.dispatchEvent(new CustomEvent('reload-customer-section-data'));" : "require(['Magento_Customer/js/customer-data'], customerData => { @@ -102,6 +109,7 @@ public function getPrerenderingScript(): string */ public function getSpeculationRules(): array { + // Possible future development: add support for multiple modes and rulesets at once. return [ $this->getMode() => [ [ @@ -127,17 +135,25 @@ public function getSpeculationRulesJson(): string */ protected function buildRules(): array { + // Include all URLs by default $rules = [ 'and' => [ ['href_matches' => '/*'] ], ]; + // Exclude path patterns (wildcards) $rules['and'][] = $this->getExcludedPaths(); + // Exclude file extensions array_push($rules['and'], ...$this->getExcludedExtensions()); + + // Exclude selectors array_push($rules['and'], ...$this->getExcludedSelectors()); + // TODO: Add extensibility? + + // Always exclude common unsafe targets $rules['and'][] = ['not' => ['selector_matches' => '[rel=nofollow]']]; $rules['and'][] = ['not' => ['selector_matches' => '[target=_blank]']]; $rules['and'][] = ['not' => ['selector_matches' => '[target=_parent]']]; @@ -185,13 +201,15 @@ public function getExcludedPaths(): array */ public function getExcludedExtensions(): array { + $rules = []; + $extensions = explode(',', (string)$this->getConfigValue('exclude_extensions')); $extensions = array_filter(array_map('trim', $extensions)); + foreach ($extensions as $extension) { + $rules[] = ['not' => ['href_matches' => sprintf('*.%s', ltrim($extension, '.'))]]; + } - return array_map( - static fn(string $extension): array => ['not' => ['href_matches' => sprintf('*.%s', ltrim($extension, '.'))]], - array_values($extensions) - ); + return $rules; } /** @@ -199,16 +217,20 @@ public function getExcludedExtensions(): array */ public function getExcludedSelectors(): array { + $rules = []; + $selectors = explode("\n", (string)$this->getConfigValue('exclude_selectors')); $selectors = array_filter(array_map('trim', $selectors)); + foreach ($selectors as $selector) { + $rules[] = ['not' => ['selector_matches' => $selector]]; + } - return array_map( - static fn(string $selector): array => ['not' => ['selector_matches' => $selector]], - array_values($selectors) - ); + return $rules; } /** + * Check if current theme is Hyva or extends from Hyva + * * @return bool */ protected function isHyva(): bool diff --git a/ViewModel/ViewTransitions.php b/ViewModel/ViewTransitions.php index 434eaa9..ae3a3c4 100644 --- a/ViewModel/ViewTransitions.php +++ b/ViewModel/ViewTransitions.php @@ -8,7 +8,7 @@ class ViewTransitions implements ArgumentInterface { - protected const string CONFIG_PATH = 'system/view_transitions/'; + protected const CONFIG_PATH = 'system/view_transitions/'; public function __construct( protected ScopeConfigInterface $scopeConfig From 44f70ce5334fc24f67069faf1d14dbbfcabc780b Mon Sep 17 00:00:00 2001 From: Ryan Hoerr Date: Mon, 11 May 2026 21:16:28 -0400 Subject: [PATCH 3/4] Fix unit tests on PHPUnit 12 - Replace `@dataProvider` PHPDoc annotations with `#[DataProvider]` attributes (PHPUnit 12 dropped the doctype form, which caused testIsEnabled and testGetEagerness to be invoked with no arguments). - Make the two data-provider methods static (PHPUnit 12 requirement). - Type the variant `$configValue` parameter as `mixed`. - Migrate `phpunit.xml.dist` to the current schema (drops `verbose` attribute, adds `cacheDirectory`). Co-Authored-By: Claude Opus 4.7 (1M context) --- Test/Unit/ViewModel/SpeculationRulesTest.php | 17 ++++++-------- phpunit.xml.dist | 24 +++++++++----------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/Test/Unit/ViewModel/SpeculationRulesTest.php b/Test/Unit/ViewModel/SpeculationRulesTest.php index e6d66b8..f03fb45 100644 --- a/Test/Unit/ViewModel/SpeculationRulesTest.php +++ b/Test/Unit/ViewModel/SpeculationRulesTest.php @@ -9,6 +9,7 @@ use Magento\Framework\View\Element\Block\ArgumentInterface; use Magento\Store\Model\ScopeInterface; use MageOS\ThemeOptimization\ViewModel\SpeculationRules; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class SpeculationRulesTest extends TestCase @@ -67,10 +68,8 @@ public function testImplementsArgumentInterface(): void // Test Category #2: Configuration Tests - isEnabled() method - /** - * @dataProvider enabledDataProvider - */ - public function testIsEnabled($configValue, bool $expected): void + #[DataProvider('enabledDataProvider')] + public function testIsEnabled(mixed $configValue, bool $expected): void { $this->scopeConfigMock->expects($this->once()) ->method('getValue') @@ -81,7 +80,7 @@ public function testIsEnabled($configValue, bool $expected): void $this->assertEquals($expected, $result); } - public function enabledDataProvider(): array + public static function enabledDataProvider(): array { return [ 'string true' => ['1', true], @@ -98,10 +97,8 @@ public function enabledDataProvider(): array // Test Category #3: Eagerness Mode Tests - /** - * @dataProvider eagernessDataProvider - */ - public function testGetEagerness($configValue, string $expected): void + #[DataProvider('eagernessDataProvider')] + public function testGetEagerness(mixed $configValue, string $expected): void { $this->scopeConfigMock->expects($this->once()) ->method('getValue') @@ -112,7 +109,7 @@ public function testGetEagerness($configValue, string $expected): void $this->assertEquals($expected, $result); } - public function eagernessDataProvider(): array + public static function eagernessDataProvider(): array { return [ 'conservative' => ['conservative', 'conservative'], diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 34ac61a..818daf1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,15 +1,13 @@ - - - - Test/Unit - - - - - - - + + + + Test/Unit + + + + + + + From 95c089574d0d50be9d619f4a51fde3a9933ac02e Mon Sep 17 00:00:00 2001 From: Ryan Hoerr Date: Mon, 11 May 2026 21:24:14 -0400 Subject: [PATCH 4/4] Update changelog for version 2.3.0 release Updated release date for version 2.3.0 and added new features and improvements. --- CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8620668..632d155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [2.3.0] - 2026-04-21 +## [2.3.0] - 2026-05-11 ### Added - PHP 8.4 and PHP 8.5 compatibility. @@ -13,7 +13,3 @@ - Typed nullable parameters explicitly (e.g. `int|string|null $store`) to avoid the PHP 8.4 implicit nullable deprecation. - Made constructor-promoted properties and helper methods `protected` instead of `private` for easier extension by downstream modules. - Minor refactor: `unset` of loop reference variable after foreach-by-reference. - -### Notes -- No public API change: all previously `public` methods keep the same signatures. -- Extensions that relied on subclassing internals now benefit from `protected` visibility.