Commit 446b86978d for woocommerce
commit 446b86978d64272cccbaf7a47852c818c8563d45
Author: Tony Arcangelini <33258733+arcangelini@users.noreply.github.com>
Date: Thu Feb 19 16:11:22 2026 +0100
Fix memory leaks in vendor-prefixed CSS inlining dependencies (#63365)
Add post-install composer patch script that applies upstream memory leak
fixes to emogrifier and symfony/css-selector vendor-prefixed packages:
- DeclarationBlockParser: add clearCache() to reset unbounded static cache
- CssInliner: call DeclarationBlockParser::clearCache() in clearAllCaches()
- CssSelectorConverter: replace unbounded toXPath() cache with LRU (200 items)
Patches are idempotent and survive dependency regeneration via Mozart.
diff --git a/packages/php/email-editor/changelog/fix-memory-leak-css-caches b/packages/php/email-editor/changelog/fix-memory-leak-css-caches
new file mode 100644
index 0000000000..0a33f5fc84
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-memory-leak-css-caches
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix unbounded static cache memory leaks in vendor-prefixed CSS inlining dependencies (emogrifier and symfony/css-selector) for long-running processes.
diff --git a/packages/php/email-editor/vendor-prefixed/composer.json b/packages/php/email-editor/vendor-prefixed/composer.json
index 40e38a3a44..1e62950c85 100644
--- a/packages/php/email-editor/vendor-prefixed/composer.json
+++ b/packages/php/email-editor/vendor-prefixed/composer.json
@@ -16,10 +16,12 @@
},
"scripts": {
"post-install-cmd": [
- "\"../../../../plugins/woocommerce/vendor/bin/mozart\" compose"
+ "\"../../../../plugins/woocommerce/vendor/bin/mozart\" compose",
+ "php patches/patch-memory-leaks.php"
],
"post-update-cmd": [
- "\"../../../../plugins/woocommerce/vendor/bin/mozart\" compose"
+ "\"../../../../plugins/woocommerce/vendor/bin/mozart\" compose",
+ "php patches/patch-memory-leaks.php"
]
},
"extra": {
diff --git a/packages/php/email-editor/vendor-prefixed/packages/Pelago/Emogrifier/CssInliner.php b/packages/php/email-editor/vendor-prefixed/packages/Pelago/Emogrifier/CssInliner.php
index 6e62628c5c..1cadee8a0f 100644
--- a/packages/php/email-editor/vendor-prefixed/packages/Pelago/Emogrifier/CssInliner.php
+++ b/packages/php/email-editor/vendor-prefixed/packages/Pelago/Emogrifier/CssInliner.php
@@ -389,6 +389,7 @@ final class CssInliner extends AbstractHtmlProcessor
self::CACHE_KEY_SELECTOR => [],
self::CACHE_KEY_COMBINED_STYLES => [],
];
+ DeclarationBlockParser::clearCache();
}
/**
diff --git a/packages/php/email-editor/vendor-prefixed/packages/Pelago/Emogrifier/Utilities/DeclarationBlockParser.php b/packages/php/email-editor/vendor-prefixed/packages/Pelago/Emogrifier/Utilities/DeclarationBlockParser.php
index 40744d80b9..1a8dc5b372 100644
--- a/packages/php/email-editor/vendor-prefixed/packages/Pelago/Emogrifier/Utilities/DeclarationBlockParser.php
+++ b/packages/php/email-editor/vendor-prefixed/packages/Pelago/Emogrifier/Utilities/DeclarationBlockParser.php
@@ -19,6 +19,17 @@ final class DeclarationBlockParser
*/
private static $cache = [];
+ /**
+ * Clears the static declaration block cache.
+ *
+ * This should be called between processing separate HTML documents to prevent
+ * unbounded memory growth in long-running processes.
+ */
+ public static function clearCache(): void
+ {
+ self::$cache = [];
+ }
+
/**
* CSS custom properties (variables) have case-sensitive names, so their case must be preserved.
* Standard CSS properties have case-insensitive names, which are converted to lowercase.
diff --git a/packages/php/email-editor/vendor-prefixed/packages/Symfony/Component/CssSelector/CssSelectorConverter.php b/packages/php/email-editor/vendor-prefixed/packages/Symfony/Component/CssSelector/CssSelectorConverter.php
index 6147278b4b..53b76a2bf6 100644
--- a/packages/php/email-editor/vendor-prefixed/packages/Symfony/Component/CssSelector/CssSelectorConverter.php
+++ b/packages/php/email-editor/vendor-prefixed/packages/Symfony/Component/CssSelector/CssSelectorConverter.php
@@ -29,6 +29,13 @@ class CssSelectorConverter
private $translator;
private $cache;
+ /**
+ * Maximum number of cached items per prefix before LRU eviction kicks in.
+ *
+ * @var int
+ */
+ public static $maxCachedItems = 200;
+
private static $xmlCache = [];
private static $htmlCache = [];
@@ -64,6 +71,21 @@ class CssSelectorConverter
*/
public function toXPath(string $cssExpr, string $prefix = 'descendant-or-self::')
{
- return $this->cache[$prefix][$cssExpr] ?? $this->cache[$prefix][$cssExpr] = $this->translator->cssToXPath($cssExpr, $prefix);
+ if (isset($this->cache[$prefix][$cssExpr])) {
+ // Promote to most-recently-used position.
+ $value = $this->cache[$prefix][$cssExpr];
+ unset($this->cache[$prefix][$cssExpr]);
+
+ return $this->cache[$prefix][$cssExpr] = $value;
+ }
+
+ $value = $this->translator->cssToXPath($cssExpr, $prefix);
+
+ if (\count($this->cache[$prefix] ?? []) >= self::$maxCachedItems) {
+ // Evict least-recently-used entry.
+ unset($this->cache[$prefix][\array_key_first($this->cache[$prefix])]);
+ }
+
+ return $this->cache[$prefix][$cssExpr] = $value;
}
}
diff --git a/packages/php/email-editor/vendor-prefixed/patches/patch-memory-leaks.php b/packages/php/email-editor/vendor-prefixed/patches/patch-memory-leaks.php
new file mode 100644
index 0000000000..bfb5f7202a
--- /dev/null
+++ b/packages/php/email-editor/vendor-prefixed/patches/patch-memory-leaks.php
@@ -0,0 +1,152 @@
+<?php
+/**
+ * Post-install patch for memory leak fixes in vendor-prefixed dependencies.
+ *
+ * Applies three patches:
+ * A) DeclarationBlockParser: adds clearCache() static method
+ * B) CssInliner: calls DeclarationBlockParser::clearCache() in clearAllCaches()
+ * C) CssSelectorConverter: replaces unbounded cache with LRU-capped version
+ *
+ * Based on upstream fixes:
+ * - https://github.com/symfony/symfony/pull/63400
+ * - https://github.com/MyIntervals/emogrifier/pull/1567
+ *
+ * @package WooCommerce\EmailEditor
+ */
+
+$base = __DIR__ . '/../packages';
+
+$patches = array();
+
+// --- Patch A: DeclarationBlockParser — add clearCache() method ---
+$patches[] = array(
+ 'file' => $base . '/Pelago/Emogrifier/Utilities/DeclarationBlockParser.php',
+ 'marker' => 'public static function clearCache(): void',
+ 'search' => ' private static $cache = [];
+
+ /**
+ * CSS custom properties (variables) have case-sensitive names, so their case must be preserved.',
+ 'replace' => ' private static $cache = [];
+
+ /**
+ * Clears the static declaration block cache.
+ *
+ * This should be called between processing separate HTML documents to prevent
+ * unbounded memory growth in long-running processes.
+ */
+ public static function clearCache(): void
+ {
+ self::$cache = [];
+ }
+
+ /**
+ * CSS custom properties (variables) have case-sensitive names, so their case must be preserved.',
+);
+
+// --- Patch B: CssInliner — call DeclarationBlockParser::clearCache() ---
+$patches[] = array(
+ 'file' => $base . '/Pelago/Emogrifier/CssInliner.php',
+ 'marker' => 'DeclarationBlockParser::clearCache();',
+ 'search' => ' private function clearAllCaches(): void
+ {
+ $this->caches = [
+ self::CACHE_KEY_SELECTOR => [],
+ self::CACHE_KEY_COMBINED_STYLES => [],
+ ];
+ }',
+ 'replace' => ' private function clearAllCaches(): void
+ {
+ $this->caches = [
+ self::CACHE_KEY_SELECTOR => [],
+ self::CACHE_KEY_COMBINED_STYLES => [],
+ ];
+ DeclarationBlockParser::clearCache();
+ }',
+);
+
+// --- Patch C: CssSelectorConverter — LRU cache ---
+$patches[] = array(
+ 'file' => $base . '/Symfony/Component/CssSelector/CssSelectorConverter.php',
+ 'marker' => 'maxCachedItems',
+ 'search' => ' private $translator;
+ private $cache;
+
+ private static $xmlCache = [];
+ private static $htmlCache = [];',
+ 'replace' => ' private $translator;
+ private $cache;
+
+ /**
+ * Maximum number of cached items per prefix before LRU eviction kicks in.
+ *
+ * @var int
+ */
+ public static $maxCachedItems = 200;
+
+ private static $xmlCache = [];
+ private static $htmlCache = [];',
+);
+
+$patches[] = array(
+ 'file' => $base . '/Symfony/Component/CssSelector/CssSelectorConverter.php',
+ 'marker' => 'array_key_first',
+ 'search' => ' public function toXPath(string $cssExpr, string $prefix = \'descendant-or-self::\')
+ {
+ return $this->cache[$prefix][$cssExpr] ?? $this->cache[$prefix][$cssExpr] = $this->translator->cssToXPath($cssExpr, $prefix);
+ }',
+ 'replace' => ' public function toXPath(string $cssExpr, string $prefix = \'descendant-or-self::\')
+ {
+ if (isset($this->cache[$prefix][$cssExpr])) {
+ // Promote to most-recently-used position.
+ $value = $this->cache[$prefix][$cssExpr];
+ unset($this->cache[$prefix][$cssExpr]);
+
+ return $this->cache[$prefix][$cssExpr] = $value;
+ }
+
+ $value = $this->translator->cssToXPath($cssExpr, $prefix);
+
+ if (\count($this->cache[$prefix] ?? []) >= self::$maxCachedItems) {
+ // Evict least-recently-used entry.
+ unset($this->cache[$prefix][\array_key_first($this->cache[$prefix])]);
+ }
+
+ return $this->cache[$prefix][$cssExpr] = $value;
+ }',
+);
+
+$failed = false;
+
+foreach ( $patches as $patch ) {
+ $name = basename( $patch['file'] );
+
+ if ( ! file_exists( $patch['file'] ) ) {
+ echo "FAIL: File not found: {$patch['file']}\n";
+ $failed = true;
+ continue;
+ }
+
+ $content = file_get_contents( $patch['file'] );
+
+ if ( strpos( $content, $patch['marker'] ) !== false ) {
+ echo "SKIP: {$name} — already patched ({$patch['marker']})\n";
+ continue;
+ }
+
+ if ( strpos( $content, $patch['search'] ) === false ) {
+ echo "FAIL: {$name} — search string not found. File may have changed upstream.\n";
+ $failed = true;
+ continue;
+ }
+
+ $patched = str_replace( $patch['search'], $patch['replace'], $content );
+ file_put_contents( $patch['file'], $patched );
+ echo "OK: {$name} — patch applied\n";
+}
+
+if ( $failed ) {
+ echo "\nSome patches failed. Please check the output above.\n";
+ exit( 1 );
+}
+
+echo "\nAll patches applied successfully.\n";