Add randomness for post selection - #14018
aaronjorbin wants to merge 2 commits into
Conversation
Random content redirects are built on the existing rewrite and query layers. random is a public query var, so ?random works on any archive, search or home URL. WP_Rewrite::random_rewrite_rules(), prefixed by the new $random_base, uses generate_rewrite_rules() to build /random/, /random/{taxonomy}/{term}/, /random/author/{name}/ and /random/{post-type-archive}/. These rules are merged ahead of the greedy page and post rules. Which content can be picked is set by a new randomable post type argument, which falls back to publicly_queryable and then to public. Posts are randomable; pages and attachments are not. Rules are only generated for randomable types and for taxonomies attached to them. A published page at the random base takes precedence over /random/. That check happens when the rules are generated, and wp_after_insert_post and after_delete_post flush the rules whenever such a page changes. The trade-off is that request time stays free of the extra lookup, at the cost of a rule flush whenever that one page changes. It also makes the rules depend on content, so a stale cache could briefly show the wrong winner. Separately, rules for a disabled feature or a newly registered type only appear after a flush, which matches how the rest of the rewrite API behaves.
The main query does the random pick. A late pre_get_posts callback at priority 1000 sets one row, orderby=rand, published and non-password posts, no stickies, no found rows and no cache priming. It also neutralizes the overrides themes commonly set: nopaging, posts_per_archive_page, showposts and offset. A split_the_query callback stops WP_Query from splitting the request, because random queries are never cached. The result is that /random/ costs exactly one database query, and nothing is queried before it. Every WP_Query filter still applies, which was Peter's main objection to the reverted version. The late priority protects randomness from theme archive tweaks. The cost is that developers who want to change the selection must use wp_is_random_content_query() inside pre_get_posts, and they can't widen eligibility past randomable. ORDER BY RAND() is a full scan of the eligible rows. It was accepted over a COUNT plus random OFFSET, which takes two queries, because one query was the stated goal; very large sites may eventually want a cheaper strategy behind a filter. The redirect itself is a 302 sent on send_headers, before the wp action and template loading, with no-cache and noindex headers added through wp_headers. Singular, feed, admin, non-GET/HEAD and empty-archive requests fall through to normal rendering. A permalink on a host wp_safe_redirect() won't allow is skipped instead of being replaced by its dashboard fallback. That favors safety over following plugins that point permalinks to other domains.
Trac Ticket: https://core.trac.wordpress.org/ticket/64498
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
Parameter #2 $fallback_url of function wp_validate_redirect expects string, false given.
|
Gave this a spin locally and it's working well! I used AI to put in some filler content. Testing with just /random: Screen.Recording.2026-10-05.at.12.39.51.PM.movTesting with /random/author/[name]: Screen.Recording.2026-10-05.at.12.40.31.PM.movI also tested with an author without any posts and it showed no results: Screen.Recording.2026-10-05.at.12.42.51.PM.mov |
peterwilsoncc
left a comment
There was a problem hiding this comment.
I've done a first pass review and left a few comments inline.
This is looking pretty good generally.
| /* | ||
| * Only redirect to locations permitted by wp_safe_redirect(). Off-site permalinks would | ||
| * otherwise be replaced by its fallback, sending the visitor to the dashboard. | ||
| */ | ||
| if ( ! $location || ! wp_validate_redirect( $location, '' ) ) { |
There was a problem hiding this comment.
wp_safe_redirect() calls wp_validate_redirect() so there's no need to validate twice.
| /* | |
| * Only redirect to locations permitted by wp_safe_redirect(). Off-site permalinks would | |
| * otherwise be replaced by its fallback, sending the visitor to the dashboard. | |
| */ | |
| if ( ! $location || ! wp_validate_redirect( $location, '' ) ) { | |
| if ( ! $location ) { |
| 'publicly_queryable' => true, | ||
| 'randomable' => true, |
There was a problem hiding this comment.
| 'publicly_queryable' => true, | |
| 'randomable' => true, | |
| 'publicly_queryable' => true, | |
| 'randomable' => true, | |
| 'has_archive' => true, |
| return; | ||
| } | ||
|
|
||
| foreach ( array( $post, $post_before ) as $page ) { |
There was a problem hiding this comment.
The rewrite flush will need if:
- the permalinks use
/%postname%/, - a post with the slug
randomis created, and, - the blog doesn't have a prefix (I think this is only a multi-site thing without a filter).
| $args['embeddable'] ??= $args['public']; | ||
|
|
||
| // If not set, default to the setting for 'publicly_queryable'. | ||
| $args['randomable'] ??= $args['publicly_queryable']; |
| * @since 7.2.0 | ||
| * @var string | ||
| */ | ||
| public $random_base = 'random'; |
There was a problem hiding this comment.
This will need to be an option on the permalink screen for i18n: French users may want to change this to aléatoire, for example.
I can't find if the default values are translatable for the category and tag bases but it will be good to check in with the polyglot team. If the other bases aren't translatable it would be good to check with the team if that was a backward compatibility choice at the time to avoid breaking URLs.
| add_filter( 'wp_redirect', array( $this, 'filter_wp_redirect' ), 10, 2 ); | ||
| } | ||
|
|
||
| public function tear_down() { |
There was a problem hiding this comment.
I think all of these are reset in the base tear down. Leaving confirmation as homework.
Post types certainly are.
| array_merge( | ||
| array( | ||
| 'post_status' => 'publish', | ||
| 'post_date' => '2022-01-01 00:00:00', |
There was a problem hiding this comment.
All I am saying is that:
- Hamilton debut was on August 6, 2015
- Dear Evan Hansen debut was December 4, 2016 (I'm still crying)
- Shucked debut was April 4, 2023
| 'future' => array( | ||
| array( | ||
| 'post_status' => 'future', | ||
| 'post_date' => '2099-01-01 00:00:00', |
There was a problem hiding this comment.
I'm the worst but technically this is a flaky test whereas time() + YEAR_IN_SECONDS is not.
| $this->go_to( home_url( '/random/category/empty/' ) ); | ||
|
|
||
| $this->assertNotRedirected( 'An empty archive has nothing to redirect to.' ); | ||
| $this->assertTrue( is_category( 'empty' ), 'The empty archive should be displayed.' ); |
There was a problem hiding this comment.
I think a 404 is more appropriate.
| add_action( 'attachment_updated', 'wp_check_for_changed_dates', 12, 3 ); | ||
|
|
||
| // Random content redirects, see wp_is_random_content_redirect_enabled(). | ||
| add_action( 'pre_get_posts', 'wp_random_content_pre_get_posts', 1000 ); |
There was a problem hiding this comment.
If you keep this instead of setting the values in WP then I think it can run at the default of 10 so plugins filtering the results get the populated values.
| * | ||
| * @since 7.2.0 | ||
| * | ||
| * @param string[] $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern. |
There was a problem hiding this comment.
| * @param string[] $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern. | |
| * @param array<string, string> $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern. |
| * | ||
| * @see wp_is_random_content_redirect_enabled() | ||
| * | ||
| * @return string[] Array of rewrite rules for random content redirects, keyed by their regex pattern. |
There was a problem hiding this comment.
| * @return string[] Array of rewrite rules for random content redirects, keyed by their regex pattern. | |
| * @return array<string, string> Array of rewrite rules for random content redirects, keyed by their regex pattern. |
| * @param WP_Query $query The query to check. | ||
| * @return bool True if the query is a request for random content, false otherwise. | ||
| */ | ||
| function wp_is_random_content_query( $query ) { |
There was a problem hiding this comment.
| function wp_is_random_content_query( $query ) { | |
| function wp_is_random_content_query( WP_Query $query ): bool { |
| * @param WP_Query $query The query to retrieve the post types for. | ||
| * @return string[] Post type names eligible for random selection. | ||
| */ | ||
| function wp_get_random_content_post_types( $query ) { |
There was a problem hiding this comment.
| function wp_get_random_content_post_types( $query ) { | |
| function wp_get_random_content_post_types( WP_Query $query ): array { |
| * @see register_post_type() | ||
| * | ||
| * @param WP_Query $query The query to retrieve the post types for. | ||
| * @return string[] Post type names eligible for random selection. |
There was a problem hiding this comment.
| * @return string[] Post type names eligible for random selection. | |
| * @return string[] Post type names eligible for random selection. | |
| * | |
| * @phpstan-return list<string> |
| * @param string[] $headers Associative array of headers to be sent. | ||
| * @return string[] Associative array of headers to be sent. | ||
| */ | ||
| function wp_random_content_headers( $headers ) { |
There was a problem hiding this comment.
| function wp_random_content_headers( $headers ) { | |
| function wp_random_content_headers( $headers ): array { |
| * | ||
| * @return bool True if random content redirects are enabled, false otherwise. | ||
| */ | ||
| function wp_is_random_content_redirect_enabled() { |
There was a problem hiding this comment.
| function wp_is_random_content_redirect_enabled() { | |
| function wp_is_random_content_redirect_enabled(): bool { |
| * | ||
| * @param WP_Query $query The WP_Query instance (passed by reference). | ||
| */ | ||
| function wp_random_content_pre_get_posts( $query ) { |
There was a problem hiding this comment.
| function wp_random_content_pre_get_posts( $query ) { | |
| function wp_random_content_pre_get_posts( $query ): void { |
| * | ||
| * @global WP_Query $wp_query WordPress Query object. | ||
| */ | ||
| function wp_random_content_redirect() { |
There was a problem hiding this comment.
| function wp_random_content_redirect() { | |
| function wp_random_content_redirect(): void { |
| * @param bool $update Optional. Whether this is an existing post being updated. Default false. | ||
| * @param WP_Post|null $post_before Optional. Post object before the update, null for new posts. Default null. | ||
| */ | ||
| function wp_random_content_flush_rewrite_rules_for_page( $post_id, $post, $update = false, $post_before = null ) { |
There was a problem hiding this comment.
| function wp_random_content_flush_rewrite_rules_for_page( $post_id, $post, $update = false, $post_before = null ) { | |
| function wp_random_content_flush_rewrite_rules_for_page( int $post_id, WP_Post $post, bool $update = false, ?WP_Post $post_before = null ): void { |
|
i forgot to mention: the db version will need a bump in order to trigger the upgrade routine and a rewrite rules update. |
| * | ||
| * @return string[] Array of rewrite rules for random content redirects, keyed by their regex pattern. | ||
| */ | ||
| public function random_rewrite_rules() { |
There was a problem hiding this comment.
| public function random_rewrite_rules() { | |
| public function random_rewrite_rules(): array { |
Random content redirects are built on the existing rewrite and query layers. random is a public query var, so ?random works on any archive, search or home URL. WP_Rewrite::random_rewrite_rules(), prefixed by the new $random_base, uses generate_rewrite_rules() to build /random/, /random/{taxonomy}/{term}/, /random/author/{name}/ and /random/{post-type-archive}/. These rules are merged ahead of the greedy page and post rules. Which content can be picked is set by a new randomable post type argument, which falls back to publicly_queryable and then to public. Posts are randomable; pages and attachments are not. Rules are only generated for randomable types and for taxonomies attached to them. A published page at the random base takes precedence over /random/. That check happens when the rules are generated, and wp_after_insert_post and after_delete_post flush the rules whenever such a page changes. The trade-off is that request time stays free of the extra lookup, at the cost of a rule flush whenever that one page changes. It also makes the rules depend on content, so a stale cache could briefly show the wrong winner. Separately, rules for a disabled feature or a newly registered type only appear after a flush, which matches how the rest of the rewrite API behaves.
The main query does the random pick. A late pre_get_posts callback at priority 1000 sets one row, orderby=rand, published and non-password posts, no stickies, no found rows and no cache priming. It also neutralizes the overrides themes commonly set: nopaging, posts_per_archive_page, showposts and offset. A split_the_query callback stops WP_Query from splitting the request, because random queries are never cached. The result is that /random/ costs exactly one database query, and nothing is queried before it. Every WP_Query filter still applies, which was Peter's main objection to the reverted version. The late priority protects randomness from theme archive tweaks. The cost is that developers who want to change the selection must use wp_is_random_content_query() inside pre_get_posts, and they can't widen eligibility past randomable. ORDER BY RAND() is a full scan of the eligible rows. It was accepted over a COUNT plus random OFFSET, which takes two queries, because one query was the stated goal; very large sites may eventually want a cheaper strategy behind a filter. The redirect itself is a 302 sent on send_headers, before the wp action and template loading, with no-cache and noindex headers added through wp_headers. Singular, feed, admin, non-GET/HEAD and empty-archive requests fall through to normal rendering. A permalink on a host wp_safe_redirect() won't allow is skipped instead of being replaced by its dashboard fallback. That favors safety over following plugins that point permalinks to other domains.
Trac Ticket: https://core.trac.wordpress.org/ticket/64498
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 5.5
Used for: basically everything
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.