Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions inc/common/purge.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,15 @@ function rocket_get_purge_urls( $post_id, $post ) {
$purge_urls[] = $author_url;
}

// Add all parents.
$parents = get_post_ancestors( $post_id );
if ( (bool) $parents ) {
foreach ( $parents as $parent_id ) {
$purge_urls[] = get_permalink( $parent_id );
$post_name = get_post_field( 'post_name', $post_id );
// If the slug has changed, add all children pages with the new slug.
if ( $post_name !== $post->post_name ) {
// Add all children.
$children = rocket_get_all_descendant( $post_id );
if ( (bool) $children ) {
foreach ( $children as $child_id ) {
$purge_urls[] = get_permalink( $child_id );
}
}
}

Expand All @@ -146,6 +150,27 @@ function rocket_get_purge_urls( $post_id, $post ) {
}
}

/**
* Recursively retrieves all descendant page IDs for a given parent page ID.
*
* @param int $parent_id The ID of the parent page.
* @return int[] A flat array with all descendant page IDs (children, grandchildren, etc.).
*/
function rocket_get_all_descendant( $parent_id ) {
// 'child_of' retrieves all descendants, not just direct children.
$all_descendants = get_pages(
[ 'child_of' => $parent_id ]
);

if ( empty( $all_descendants ) ) {
return [];
}

// Convert the array of page objects to an array of IDs only.
return wp_list_pluck( $all_descendants, 'ID' );
}


/**
* Update cache when a post is updated or commented
*
Expand Down Expand Up @@ -636,6 +661,20 @@ function rocket_clean_post_cache_on_slug_change( $post_id, $post_data ) {
if ( empty( $post_name ) ) {
return;
}
rocket_clean_files( get_the_permalink( $post_id ) );

$purge_urls = [];
$purge_urls[] = get_the_permalink( $post_id );

// Clear cache for all child pages.
$children = rocket_get_all_descendant( $post_id );
if ( (bool) $children ) {
foreach ( $children as $child_id ) {
$purge_urls[] = get_the_permalink( $child_id );
}
}

rocket_clean_files( $purge_urls );
Comment on lines +665 to +676
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rocket_clean_post_cache_on_slug_change() now purges an array of URLs (parent + all descendants) instead of a single permalink, and it introduces new behavior (child pages being cleared when a parent slug changes), but existing unit/integration tests for this function currently only assert a single call to rocket_clean_files() with one URL and don’t verify that child URLs are included. Please update those tests to expect the new array-based call signature and add assertions/fixtures that cover the descendant purge behavior so this logic is protected against regressions.

Copilot generated this review using guidance from repository custom instructions.

}
add_action( 'pre_post_update', 'rocket_clean_post_cache_on_slug_change', PHP_INT_MAX, 2 );

148 changes: 111 additions & 37 deletions inc/functions/files.php
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,32 @@ function rocket_maybe_find_right_trash_url( array $parsed_url, int $post_id ) {
return get_rocket_parse_url( $new_permalink );
}


/**
* Count the number of path segments in a URL.
*
* Parses the given URL, extracts its path component, trims leading/trailing
* slashes, and counts the remaining segments separated by '/'.
*
* Examples:
* - "https://example.com/" => 0
* - "https://example.com/a/b/c" => 3
* - "/a/b/" => 2
*
* @param string $url The URL (absolute or relative) to analyze.
* @return int Number of segments in the URL path (0 if empty or no path).
*/
function rocket_count_path_segments( string $url ): int {
$path = wp_parse_url( $url, PHP_URL_PATH ) ?? '';
$path = trim( $path, '/' ); // "/" -> ""

if ( '' === $path ) {
return 0;
}

return count( explode( '/', $path ) ); // "a/b/c" -> 3
}

/**
* Delete one or several cache files.
*
Expand Down Expand Up @@ -563,6 +589,23 @@ function rocket_clean_files( $urls, $filesystem = null, $run_actions = true ) {
$filesystem = rocket_direct_filesystem();
}

// Sort: most segments first (deepest URLs first).
usort(
$urls,
static function ( $a, $b ) {
$da = rocket_count_path_segments( (string) $a );
$db = rocket_count_path_segments( (string) $b );

// First by depth (descending).
if ( $da !== $db ) {
return $db <=> $da;
}

// If depth is equal, alphabetically.
return strcmp( (string) $a, (string) $b );
}
);
Comment on lines +592 to +607
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introducing the usort() here changes the order of $urls before firing the before_rocket_clean_files and before_rocket_clean_file actions, and it also means deeper URLs are processed first. Existing tests and 3rd-party code may implicitly rely on the previous iteration order, so it would be good to (1) update the unit tests for rocket_clean_files() to assert the new ordering semantics where relevant and (2) document in tests/fixtures that purge order is depth-first (deepest paths first) to guard against accidental reordering in future refactors.

Copilot generated this review using guidance from repository custom instructions.

if ( $run_actions ) {
/**
* Fires before all cache files are deleted.
Expand All @@ -575,6 +618,7 @@ function rocket_clean_files( $urls, $filesystem = null, $run_actions = true ) {
}

foreach ( $urls as $url_key => $url ) {

if ( $run_actions ) {
/**
* Fires before the cache file is deleted.
Expand All @@ -591,55 +635,85 @@ function rocket_clean_files( $urls, $filesystem = null, $run_actions = true ) {

$parsed_url = get_rocket_parse_url( $url );

if ( ! empty( $parsed_url['host'] ) ) {
foreach ( _rocket_get_cache_dirs( $parsed_url['host'], $cache_path ) as $dir ) {
// Decode url path.
$url_chunks = explode( '/', $parsed_url['path'] );
$matches = preg_grep( '/%/', $url_chunks );
if ( empty( $parsed_url['host'] ) ) {
continue;
}

if ( ! empty( $matches ) ) {
$parsed_url['path'] = rawurldecode( $parsed_url['path'] );
}
foreach ( _rocket_get_cache_dirs( $parsed_url['host'], $cache_path ) as $dir ) {
// Decode url path.
$url_chunks = explode( '/', $parsed_url['path'] );
$matches = preg_grep( '/%/', $url_chunks );

if ( ! empty( $matches ) ) {
$parsed_url['path'] = rawurldecode( $parsed_url['path'] );
}

// Encode Non-latin characters if found in url path.
if ( false !== preg_match_all( '/(?<non_latin>[^\x00-\x7F]+)/', $parsed_url['path'], $matches ) ) {
$cb_encode_non_latin = function ( $non_latin ) {
return strtolower( rawurlencode( $non_latin ) );
};
// Encode Non-latin characters if found in url path.
if ( false !== preg_match_all( '/(?<non_latin>[^\x00-\x7F]+)/', $parsed_url['path'], $matches ) ) {
$cb_encode_non_latin = function ( $non_latin ) {
return strtolower( rawurlencode( $non_latin ) );
};

$parsed_url['path'] = str_replace( $matches['non_latin'], array_map( $cb_encode_non_latin, $matches['non_latin'] ), $parsed_url['path'] );
}
$parsed_url['path'] = str_replace( $matches['non_latin'], array_map( $cb_encode_non_latin, $matches['non_latin'] ), $parsed_url['path'] );
}

$entry = $dir . $parsed_url['path'];

// For regex we use it for file names only, and it should include the * character.
if ( str_contains( $entry, '*' ) ) {
$regex_part = basename( $entry );
$search_dir = str_replace( $regex_part, '', $entry );
$matched_files = _rocket_get_dir_files_by_regex( $search_dir, '#' . $regex_part . '#i' );
foreach ( $matched_files as $item ) {
$current_file = $item->getPath() . DIRECTORY_SEPARATOR . $item->getFilename();
if ( $filesystem->exists( $current_file ) ) {
$filesystem->delete( $current_file );
}
$entry = $dir . $parsed_url['path'];

// For regex we use it for file names only, and it should include the * character.
if ( str_contains( $entry, '*' ) ) {
$regex_part = basename( $entry );
$search_dir = str_replace( $regex_part, '', $entry );
$matched_files = _rocket_get_dir_files_by_regex( $search_dir, '#' . $regex_part . '#i' );
foreach ( $matched_files as $item ) {
$current_file = $item->getPath() . DIRECTORY_SEPARATOR . $item->getFilename();
if ( $filesystem->exists( $current_file ) ) {
$filesystem->delete( $current_file );
}
// Remove the regex part from the url.
$url = str_replace( $regex_part, '', $url );
$urls[ $url_key ] = $url;
}
// Remove the regex part from the url.
$url = str_replace( $regex_part, '', $url );
$urls[ $url_key ] = $url;
}

// Skip if the dir/file does not exist.
if ( ! $filesystem->exists( $entry ) ) {
continue;
}

// Skip if the dir/file does not exist.
if ( ! $filesystem->exists( $entry ) ) {
continue;
if ( ! $filesystem->is_dir( $entry ) ) {
$filesystem->delete( $entry );
continue;
}

// Check whether the directory contains subfolders (vs only files).
$has_subdirs = false;
try {
foreach ( new FilesystemIterator( $entry, FilesystemIterator::SKIP_DOTS ) as $child ) {
if ( $child->isDir() ) {
$has_subdirs = true;
break;
}
}
} catch ( Exception $e ) {
// If we can't inspect the directory, be conservative and only delete files.
$has_subdirs = true;
}

if ( $filesystem->is_dir( $entry ) ) {
rocket_rrmdir( $entry, [], $filesystem );
} else {
$filesystem->delete( $entry );
if ( ! $has_subdirs ) {
// Directory contains only files: remove it entirely.
rocket_rrmdir( $entry, [], $filesystem );
continue;
}

// Directory contains subfolders: delete only the files in the top-level.
foreach ( _rocket_get_dir_files_by_regex( $entry, '#.+#' ) as $child ) {
if ( $child->isFile() ) {
Comment on lines +688 to +710
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block changes rocket_clean_files() semantics in a non-trivial way: instead of always calling rocket_rrmdir() on matching directories, it now conditionally deletes entire directories only when they have no subdirectories and otherwise removes only top-level files. Given how widely rocket_clean_files() is used (including deprecated helpers like rocket_clean_directory_for_default_language_on_wpml()), please extend the existing unit/integration tests for rocket_clean_files() to cover these new branches, including cases where a cache directory contains nested subfolders that must be preserved and verifying that only the intended files are removed.

Copilot generated this review using guidance from repository custom instructions.
$filesystem->delete( $child->getPathname() );
}
}

}

if ( $run_actions ) {
/**
* Fires after the cache file is deleted.
Expand Down