From 70db972a0f49be989a1de072bb1c268e145b65e4 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 5 Jun 2026 22:55:34 -0600 Subject: [PATCH 1/2] fix: purge Divi clone CSS cache --- inc/compat/class-general-compat.php | 151 ++++++++++++++++++++++++ tests/WP_Ultimo/General_Compat_Test.php | 116 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 tests/WP_Ultimo/General_Compat_Test.php diff --git a/inc/compat/class-general-compat.php b/inc/compat/class-general-compat.php index a7cd6aa1..4584784e 100644 --- a/inc/compat/class-general-compat.php +++ b/inc/compat/class-general-compat.php @@ -65,6 +65,8 @@ public function init(): void { */ add_filter('wu_should_redirect_to_primary_domain', [$this, 'fix_divi_editor_screen']); + add_action('wu_duplicate_site', [$this, 'clear_divi_static_css_cache'], 20); + /** * WP Hide Pro * @@ -332,6 +334,155 @@ public function fix_divi_editor_screen(bool $should_redirect): bool { return $should_redirect; } + /** + * Clear Divi's generated static CSS files after site duplication. + * + * Divi stores generated builder CSS outside the uploads tree under + * wp-content/et-cache/{network_id}/{blog_id}/. A cloned site can keep + * stale CSS generated before the copied Divi layout data is available, + * leaving page modules with default spacing/positioning until the cache is + * manually cleared. Removing only the cloned blog's cache directory forces + * Divi to rebuild the files on the next frontend request. + * + * @since 2.5.1 + * + * @param array|int|object $site Duplicated site payload. + * @return void + */ + public function clear_divi_static_css_cache($site): void { + + $blog_id = $this->get_duplicated_site_id($site); + + if (2 > $blog_id || ! defined('WP_CONTENT_DIR')) { + return; + } + + switch_to_blog($blog_id); + + try { + foreach ($this->get_divi_static_css_cache_directories($blog_id) as $cache_dir) { + $this->delete_divi_static_css_cache_directory($cache_dir); + } + } finally { + restore_current_blog(); + } + } + + /** + * Extract the duplicated site ID from the wu_duplicate_site payload. + * + * @since 2.5.1 + * + * @param array|int|object $site Duplicated site payload. + * @return int + */ + private function get_duplicated_site_id($site): int { + + if (is_array($site) && isset($site['site_id'])) { + return (int) $site['site_id']; + } + + if (is_array($site) && isset($site['blog_id'])) { + return (int) $site['blog_id']; + } + + if (is_object($site) && isset($site->blog_id)) { + return (int) $site->blog_id; + } + + if (is_object($site) && isset($site->site_id)) { + return (int) $site->site_id; + } + + return (int) $site; + } + + /** + * Get possible Divi static CSS cache directories for a cloned blog. + * + * @since 2.5.1 + * + * @param int $blog_id Blog ID. + * @return array + */ + private function get_divi_static_css_cache_directories(int $blog_id): array { + + $cache_root = trailingslashit(WP_CONTENT_DIR) . 'et-cache'; + $paths = []; + $site = function_exists('get_site') ? get_site($blog_id) : null; + $network_id = $site && isset($site->site_id) ? (int) $site->site_id : 0; + + if (0 === $network_id && function_exists('get_current_network_id')) { + $network_id = (int) get_current_network_id(); + } + + if (0 < $network_id) { + $paths[] = trailingslashit($cache_root) . $network_id . '/' . $blog_id; + } + + /* + * Older Divi/static-resource layouts did not include the network ID in + * the path. Keep this as a no-op fallback when that directory is absent. + */ + $paths[] = trailingslashit($cache_root) . $blog_id; + + return array_values(array_unique($paths)); + } + + /** + * Safely delete a Divi static CSS cache directory. + * + * @since 2.5.1 + * + * @param string $cache_dir Cache directory path. + * @return void + */ + private function delete_divi_static_css_cache_directory(string $cache_dir): void { + + $cache_root = trailingslashit(WP_CONTENT_DIR) . 'et-cache'; + + if ('' === $cache_dir || ! is_dir($cache_dir)) { + return; + } + + $real_cache_dir = realpath($cache_dir); + $real_cache_root = realpath($cache_root); + + if (false === $real_cache_dir || false === $real_cache_root) { + return; + } + + $real_cache_dir = untrailingslashit(wp_normalize_path($real_cache_dir)); + $real_cache_root = untrailingslashit(wp_normalize_path($real_cache_root)); + + if ($real_cache_root === $real_cache_dir || 0 !== strpos(trailingslashit($real_cache_dir), trailingslashit($real_cache_root))) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($real_cache_dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + $path = $file->getRealPath(); + + if (false === $path) { + continue; + } + + if ($file->isDir()) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Removes an empty generated cache directory after deleting its contents. + @rmdir($path); + } else { + wp_delete_file($path); + } + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Removes the generated cloned-site Divi cache directory. + @rmdir($real_cache_dir); + } + /** * Fixes WP Hide Pro URLs while on Ultimo's template preview * diff --git a/tests/WP_Ultimo/General_Compat_Test.php b/tests/WP_Ultimo/General_Compat_Test.php new file mode 100644 index 00000000..8af59491 --- /dev/null +++ b/tests/WP_Ultimo/General_Compat_Test.php @@ -0,0 +1,116 @@ +cache_dirs as $cache_dir) { + $this->remove_directory($cache_dir); + } + + parent::tearDown(); + } + + /** + * Test init registers the Divi cache purge duplication hook. + */ + public function test_init_registers_divi_cache_purge_hook(): void { + + $instance = General_Compat::get_instance(); + $instance->init(); + + $this->assertNotFalse(has_action('wu_duplicate_site', [$instance, 'clear_divi_static_css_cache'])); + } + + /** + * Test Divi et-cache files are deleted only for the cloned site. + */ + public function test_clear_divi_static_css_cache_deletes_cloned_site_cache_only(): void { + + if ( ! is_multisite()) { + $this->markTestSkipped('Divi cache purge tests require multisite'); + } + + $blog_id = self::factory()->blog->create(); + $other_blog_id = self::factory()->blog->create(); + $network_id = (int) get_current_network_id(); + $cache_root = trailingslashit(WP_CONTENT_DIR) . 'et-cache'; + $cache_dir = trailingslashit($cache_root) . $network_id . '/' . $blog_id; + $other_dir = trailingslashit($cache_root) . $network_id . '/' . $other_blog_id; + + $this->cache_dirs = [$cache_dir, $other_dir]; + + wp_mkdir_p($cache_dir . '/9'); + wp_mkdir_p($other_dir . '/9'); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup. + file_put_contents($cache_dir . '/9/et-core-unified-deferred-9.min.css', 'stale divi css'); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup. + file_put_contents($other_dir . '/9/et-core-unified-deferred-9.min.css', 'other divi css'); + + General_Compat::get_instance()->clear_divi_static_css_cache(['site_id' => $blog_id]); + + $this->assertDirectoryDoesNotExist($cache_dir); + $this->assertDirectoryExists($other_dir); + } + + /** + * Recursively remove a test directory. + * + * @param string $dir Directory path. + * @return void + */ + private function remove_directory(string $dir): void { + + if ('' === $dir || ! is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + $path = $file->getRealPath(); + + if (false === $path) { + continue; + } + + if ($file->isDir()) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Test fixture cleanup. + @rmdir($path); + } else { + wp_delete_file($path); + } + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Test fixture cleanup. + @rmdir($dir); + } +} From 4b1ef20d5c8542862bda35eff5d10e6473c9196e Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 6 Jun 2026 10:45:41 -0600 Subject: [PATCH 2/2] ci: bound performance browser install --- .github/workflows/tests.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e0f5318f..1f588c14 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -183,10 +183,16 @@ jobs: working-directory: .wp-performance-action/env - name: Install Playwright browsers - run: npx playwright install --with-deps + timeout-minutes: 15 + run: | + if ! timeout 12m npx playwright install --with-deps chromium; then + echo "Playwright browser install timed out or failed; skipping advisory performance metrics." + echo "PERF_SKIP=true" >> "$GITHUB_ENV" + fi working-directory: .wp-performance-action/env - name: Prepare merged blueprint + if: env.PERF_SKIP != 'true' run: | BLUEPRINT=$(realpath "$GITHUB_WORKSPACE/current/.github/performance-blueprint.json") jq -s 'map(to_entries)|flatten|group_by(.key)|map({(.[0].key):map(.value)|add})|add' \ @@ -197,6 +203,7 @@ jobs: # ── Baseline run (main branch) ───────────────────────────────── - name: Start server with baseline plugin + if: env.PERF_SKIP != 'true' run: | BASELINE_PATH=$(realpath "$GITHUB_WORKSPACE/baseline") ./node_modules/@wp-playground/cli/wp-playground.js server \ @@ -209,6 +216,7 @@ jobs: working-directory: .wp-performance-action/env - name: Wait for baseline server + if: env.PERF_SKIP != 'true' run: | echo "Waiting for baseline server on port 9400..." for i in $(seq 1 60); do @@ -223,6 +231,7 @@ jobs: exit 1 - name: Run baseline performance tests + if: env.PERF_SKIP != 'true' run: | mkdir -p "$WP_PERF_ARTIFACTS" for attempt in 1 2 3; do @@ -257,6 +266,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "true" - name: Stop baseline server + if: env.PERF_SKIP != 'true' run: | npm run stop-server 2>/dev/null || true # Clean artifacts for the PR run @@ -265,6 +275,7 @@ jobs: # ── PR branch run ─────────────────────────────────────────────── - name: Start server with PR branch plugin + if: env.PERF_SKIP != 'true' run: | CURRENT_PATH=$(realpath "$GITHUB_WORKSPACE/current") ./node_modules/@wp-playground/cli/wp-playground.js server \ @@ -276,6 +287,7 @@ jobs: working-directory: .wp-performance-action/env - name: Wait for PR server + if: env.PERF_SKIP != 'true' run: | echo "Waiting for PR server on port 9400..." for i in $(seq 1 60); do @@ -290,6 +302,7 @@ jobs: exit 1 - name: Run PR branch performance tests + if: env.PERF_SKIP != 'true' run: | mkdir -p "$WP_PERF_ARTIFACTS" for attempt in 1 2 3; do @@ -320,14 +333,14 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "true" - name: Stop server - if: always() + if: always() && env.PERF_SKIP != 'true' run: npm run stop-server 2>/dev/null || true working-directory: .wp-performance-action/env # ── Generate comparison report ────────────────────────────────── - name: Generate performance comparison report id: prepare-results - if: always() + if: always() && env.PERF_SKIP != 'true' run: | RESULTS_JSON="$WP_PERF_ARTIFACTS/performance-results.json" if [ ! -f "$RESULTS_JSON" ]; then