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";