From 8d3afebb06c9bb141aba00c0f30fa6c126bfe52b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 14:20:02 +0000 Subject: [PATCH 01/12] Apply Query Loop post exclusions in PHP instead of SQL WP_Query builds its `post-queries` cache key from the query vars and the generated SQL, so putting excluded IDs into `post__not_in` gives every URL a cache entry of its own. A "related posts" loop that excludes the post being viewed therefore never shares a cached result set with any other post, even though every one of those queries asks the same question. For non-inherited query loops, over-fetch by the number of exclusions and drop the unwanted posts on `the_posts` instead. Core writes the result to the object cache before `the_posts` runs, so the shareable superset is what gets cached and the filtering costs nothing in cache terms. Fetching `per_page + count(exclude)` rows guarantees a full page: at most one row can be dropped per excluded ID. This covers the plugin's own "exclude already displayed posts" setting, core's `excludeCurrent` block attribute, and anything added via the new `hm_query_loop_deferred_exclusions` filter. It falls back to SQL exclusion past `hm_query_loop_max_deferred_fetch`, when `hm_query_loop_defer_exclusions` is disabled, or when the query cannot reach `the_posts`. Alongside that: - Post templates now share one query. Each used to get a narrowed query of its own, with the preceding templates' posts in `post__not_in`; they now all issue the loop's own unmodified query and window the results in PHP, so N templates cost one query and one cache entry. - The plugin no longer leaks its own state into the cache key. Every custom query var is hashed into it, and `query_id` is derived from the post ID, so it was giving each loop a private key on every URL. Both it and the tracking flag are now stripped on `pre_get_posts`, before the key is generated. - `paged` is only set where `offset` is absent, since `offset` overrides it in the LIMIT clause and it was otherwise just noise in the key. - The exclusion set is snapshotted per loop, so a loop's post templates and its pagination query all exclude the same posts and share one entry. Adds unit tests for the planner that need only PHP, and docs/query-caching.md with the reasoning and what is still left to do. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- .github/workflows/playwright-tests.yml | 15 + CLAUDE.md | 19 +- README.md | 18 + docs/query-caching.md | 274 ++++++++++++++ hm-query-loop.php | 175 +++++---- inc/deferred-exclusions.php | 478 +++++++++++++++++++++++++ package.json | 1 + tests/php/deferred-exclusions-test.php | 390 ++++++++++++++++++++ 8 files changed, 1306 insertions(+), 64 deletions(-) create mode 100644 docs/query-caching.md create mode 100644 inc/deferred-exclusions.php create mode 100644 tests/php/deferred-exclusions-test.php diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 3f724c5..df8d7d7 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -7,6 +7,21 @@ on: branches: [main, master, develop] jobs: + php: + name: PHP unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - name: Run tests + run: php tests/php/deferred-exclusions-test.php + test: timeout-minutes: 60 runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 6c49285..c0562a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ HM Query Loop is a WordPress plugin that extends the core Query Loop block with - `npm run format` - Format all files ### Testing +- `npm run test:php` - Run the PHP unit tests (no WordPress or Docker needed) - `npm run wp-env start` - Start WordPress test environment (ports 8888 dev, 8889 tests) - `npm run test:e2e` - Run Playwright end-to-end tests - `npm run test:e2e:debug` - Run tests in debug mode @@ -59,7 +60,18 @@ The plugin handles two different query scenarios: ### Post Tracking - `the_posts` filter tracks displayed post IDs across all query loops on a page - Global `$displayed_post_ids` array accumulates IDs from rendered query loops -- Subsequent query loops with `excludeDisplayed` enabled filter out tracked IDs via `post__not_in` +- Subsequent query loops with `excludeDisplayed` enabled filter out tracked IDs +- The exclusion set is snapshotted per loop (`$query_loop_exclusion_snapshots`) so every query a loop runs — post templates, pagination, total — excludes the same posts and shares one cache entry + +### Deferred Exclusions (`inc/deferred-exclusions.php`) +`post__not_in` puts excluded IDs into the SQL, and `WP_Query` derives its `post-queries` cache key from the SQL, so a loop excluding the post being viewed gets a private cache entry on every URL. For non-inherited queries the plugin instead over-fetches by `count( $exclude )` and drops the posts in PHP on `the_posts` — which core runs *after* writing the result to the object cache, so the shareable superset is what gets cached. + +- `plan_query()` (`query_loop_block_query_vars`, priority 999 — after presets) turns recorded exclusions and post-template windows into a fetch plan +- Sources: core's `query.excludeCurrent`, the plugin's `excludeDisplayed`, and the `hm_query_loop_deferred_exclusions` filter +- `bind_context()` (`pre_get_posts`, priority 0) strips the plugin's state from the query vars before the cache key is generated, binding it to the `WP_Query` instance instead. **Any** custom query var reaches the cache key, so nothing this plugin tracks may be left in there +- `filter_posts()` (`the_posts`, priority 9) applies the plan and corrects `found_posts`/`max_num_pages`; it runs before post tracking at priority 10 +- Falls back to SQL exclusion when the fetch would exceed `hm_query_loop_max_deferred_fetch` (default 100), when `hm_query_loop_defer_exclusions` is false, or when the query cannot reach `the_posts` (`fields => ids`, `suppress_filters`) +- See `docs/query-caching.md` ### Editor Viewport Placeholder (Lazy Rendering) `withViewportPlaceholder` HOC (registered last, so it wraps the plugin's other `core/query` enhancements) replaces off-screen Query Loop blocks with a cheap `` that fires no REST request. Mounting the real block triggers the core preview fetch, so on a page with many query loops this defers those requests until each block scrolls near the viewport. An `IntersectionObserver` — constructed from the target node's own `ownerDocument.defaultView` so it works whether or not the canvas is iframed — swaps in the real block on intersection (with a 300px `rootMargin` preload). Selecting a block (e.g. right after insertion or via List View) renders it immediately, and once rendered a block stays rendered (latched via state) so scrolling away neither discards edits nor refetches. @@ -71,7 +83,7 @@ The plugin handles two different query scenarios: A non-inherited Query Loop can contain multiple `core/post-template` blocks, each showing a different slice of the results: - `withPostTemplateInspectorControls` HOC adds "Posts per template" to each `core/post-template`'s inspector, clamped to remaining available posts. - `withQueryLoopContextProvider` HOC wraps `core/query` with a `UsedPostsContext.Provider` so sibling post-template blocks share their `perPage` values. -- Server-side: `filter_query_loop_block_query_vars` computes `posts_per_page` and offset per template using `$query_loop_post_template_per_pages` (keyed by `queryId`). +- Server-side: `filter_query_loop_block_query_vars` records each template's window (start and size) using `$query_loop_post_template_per_pages` (keyed by `queryId`). Every template then issues the loop's own unmodified query and slices its own window out of the results in PHP, so they share one query and one cache entry. ### Query ID Deduplication WordPress does not deduplicate `queryId` when blocks are copy-pasted, breaking post exclusion and pagination: @@ -107,7 +119,10 @@ The plugin provides a PHP API for registering custom query presets that can be s - `hm-query-loop.php` - Main plugin file with all PHP hooks and query modification logic - `inc/query-presets.php` - Query presets registration API and hooks +- `inc/deferred-exclusions.php` - PHP-side post exclusion and post-template windowing +- `docs/query-caching.md` - Why exclusions are applied in PHP, and what is left to do - `src/index.js` - Block filters for adding inspector controls and editor preview behavior +- `tests/php/deferred-exclusions-test.php` - Unit tests for the exclusion planner - `tests/e2e/fixtures.js` - Playwright test fixtures for WordPress admin - `tests/e2e/posts-per-page.spec.js` - E2E tests for posts per page functionality - `tests/e2e/query-presets.spec.js` - E2E tests for query presets diff --git a/README.md b/README.md index ff48eed..69be5f3 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,14 @@ Enable this option to automatically exclude posts that have been displayed by pr **Important:** The exclusion applies to all query loops rendered before the current one, regardless of whether they were visible (e.g., hidden due to pagination settings). +For query loops that do not inherit the main query, the exclusion is applied in PHP rather than through `post__not_in`, so that loops on different URLs can share one cached result set. See [Query caching](docs/query-caching.md). + ### 4. Multiple Post Templates A single Query Loop block (non-inherited) can contain multiple `core/post-template` blocks, each showing a different slice of the query results. Each Post Template block gets a "Posts per template" setting in its inspector controls to control how many posts it shows. +All the templates in a loop run the same query and take their own window out of the results, so however many templates a loop has, it costs one database query. + ### 5. Query ID Deduplication The plugin automatically assigns unique query IDs when blocks are copy-pasted or when a page renders the same template multiple times, preventing broken post exclusion and pagination. @@ -37,6 +41,12 @@ Register custom query configurations in PHP that can be selected from a dropdown - Queries work in both the editor preview and on the frontend - Automatically hooks into all public post types via the REST API +### 7. Cache-friendly Exclusion + +Excluding posts with `post__not_in` gives every URL its own `WP_Query` cache entry, because the excluded IDs end up in the SQL the cache key is built from. For non-inherited query loops this plugin fetches a few extra posts instead and drops the unwanted ones in PHP, so "the latest 5 posts, excluding this one" is one cached query shared by every post on the site rather than one per post. + +This applies to the plugin's own exclusion setting, to core's `excludeCurrent` block attribute, and to anything added through the `hm_query_loop_deferred_exclusions` filter. See [Query caching](docs/query-caching.md) for the details and the trade-offs. + ## Installation 1. Upload the plugin to your `/wp-content/plugins/` directory @@ -60,6 +70,14 @@ The plugin includes end-to-end tests using Playwright and `@wordpress/scripts`. #### Running Tests +The exclusion planner has unit tests that need nothing but PHP: + +```bash +npm run test:php +``` + +The rest of the suite is end to end: + 1. Start the WordPress test environment: ```bash npm run wp-env start diff --git a/docs/query-caching.md b/docs/query-caching.md new file mode 100644 index 0000000..6a6ccf8 --- /dev/null +++ b/docs/query-caching.md @@ -0,0 +1,274 @@ +# Query caching + +Notes on how Query Loop blocks interact with WordPress's object cache, why post +exclusion is expensive, and what this plugin does about it. + +## The problem + +`WP_Query` caches the ID list for a query in the `post-queries` object cache +group. The cache key is: + +```php +$key = md5( serialize( $args ) . $sql ); +``` + +— that is, **the query vars and the generated SQL**. Two loops that ask the same +question share one entry; two loops whose SQL differs by a single character do +not. + +Exclusion is applied by putting IDs into `post__not_in`, which puts them into the +SQL: + +```sql +AND wp_posts.ID NOT IN (1234) +``` + +For a "related posts" or "more from this author" loop that excludes the post +being viewed, `1234` is different on every URL. A site with 50,000 posts ends up +with 50,000 cache entries for what is really one question — "the latest N posts" +— and every one of them is cold the first time it is asked. The entries are also +invalidated together (see [Invalidation](#invalidation)), so they rarely get a +second chance to be useful. + +The same thing happens, less dramatically, when a second loop on a page excludes +what the first one displayed: its key depends on the exact contents of every loop +above it. + +## The fix + +Ask the cacheable question, and do the excluding afterwards: + +> To show 5 posts excluding the current one, fetch 6 posts with no exclusion at +> all, drop the current post in PHP, and render the first 5. + +The query — and therefore the cache key — is now identical on every URL. One +entry serves the whole site. The cost is one extra row fetched and a `foreach` +over six items. + +This is only worth doing because of where the object cache write happens. + +### Where the cache write happens + +In `WP_Query::get_posts()` the order is: + +1. cache lookup (`wp_cache_get_salted( $cache_key, 'post-queries', … )`) +2. the database query +3. **the cache write** — `array( 'posts' => $post_ids, 'found_posts' => …, 'max_num_pages' => … )` +4. `posts_results` +5. sticky post handling +6. **`the_posts`** +7. `post_count` recomputed, posts re-mapped through `get_post()`, post caches primed + +In WordPress 6.9 the write is around line 3455 of `class-wp-query.php` and +`the_posts` around line 3633; the relative order has been the same since at least +6.5. + +So what lands in the cache is the **unfiltered** result — the shareable superset — +and anything removed in `the_posts` is removed per request, for this request +only. Filtering there is free in cache terms. + +### Why the over-fetch is exactly `count( $exclude )` + +Fetching `per_page + count( $exclude )` rows guarantees a full page: at most one +fetched row can be dropped per excluded ID, so at least `per_page` survive. No +second "top-up" query is ever needed, and the fetch size depends only on how many +IDs are being excluded — not on which ones — so it stays stable across URLs. + +For page _P_ of a loop the fetch starts at the block's configured offset and asks +for `per_page * P + count( $exclude )` rows, because exclusions shift the page +boundaries. That grows with the page number, which is why there is a ceiling on +it (`hm_query_loop_max_deferred_fetch`, default 100); past the ceiling the query +falls back to excluding in SQL. + +## What this plugin does + +All of the below applies to **non-inherited** query loops — the ones that build +their own `WP_Query` through `query_loop_block_query_vars`. Inherited loops run +against the main query, which already has a per-URL cache key, so there is much +less to win; see [Not done yet](#not-done-yet). + +### 1. Exclusions are applied in PHP + +`inc/deferred-exclusions.php` collects the IDs a loop wants to exclude, plans an +over-fetch, and drops them on `the_posts` (priority 9, before the plugin's own +post tracking at 10, so only posts that really render are recorded). + +Three sources feed it: + +| Source | Varies by | +|---|---| +| Core's own `query.excludeCurrent` block attribute | URL — the worst case | +| This plugin's **Exclude already displayed posts** setting | Position on the page | +| The `hm_query_loop_deferred_exclusions` filter | Whatever you want | + +Core applies `excludeCurrent` itself, in `build_query_vars_from_query_block()`, +by appending `get_the_ID()` to `post__not_in`. The plugin takes that ID back out +and handles it in PHP, so no configuration is needed to get the improvement. + +### 2. Post templates share one query + +A loop with several `core/post-template` blocks used to give each template its own +narrowed query — a smaller `posts_per_page`, and the preceding templates' post IDs +in `post__not_in`. That is N queries and N cache entries for one result set, and +editing the second template's "posts per template" invalidated the first one's +entry too. + +Now every template issues **the loop's own query, unchanged**, and takes its own +window out of the result in PHP. N templates cost one query and one cache entry — +and that entry is the same one a plain single-template loop with the same settings +would use. + +### 3. Nothing the plugin tracks reaches the cache key + +`generate_cache_key()` strips exactly seven query vars (`cache_results`, +`fields`, `lazy_load_term_meta`, `update_post_meta_cache`, +`update_post_term_cache`, `update_menu_item_cache`, `suppress_filters`) and +serialises everything else. There is no allow-list: **any** custom query var a +plugin passes to `WP_Query` becomes part of the key, whether or not it affects +the SQL. + +This plugin used to pass two: + +- `query_id` — the loop's `queryId`, which is derived from the post ID, so it gave + every loop a private cache key on every URL even when nothing else differed. +- `hm_query_loop_collect_ids` — constant, so it did not fragment the cache between + loops, but it did stop them sharing entries with any identical query from + outside the plugin. + +Both are now passed in a single query var that `bind_context()` strips on +`pre_get_posts` — before the SQL is built and before the key is generated — and +binds to the `WP_Query` instance instead. + +`paged` was also being set on non-inherited loops, where `offset` overrides it in +the LIMIT clause. It is now only set where it can actually change the result. + +### 4. `found_posts` is corrected per request, not through the filter + +The over-fetched query's `found_posts` counts posts that were then dropped, so +pagination has to be adjusted. That happens in `the_posts`, on every request, +rather than through the `found_posts` filter — because `set_found_posts()` only +runs on a **cache miss**, and its result is written into the shared cache entry. +Adjusting it there would bake one URL's correction into every other URL's answer. + +Note that the correction is a lower bound: only the fetched window is visible, so +exclusions further down the result set are not counted. + +## What it costs + +- **A few extra rows per query.** Bounded by `hm_query_loop_max_deferred_fetch`. +- **A `foreach` over the fetched rows on every request**, including cache hits. + Cheap, but not free — and because the array changes, core takes the + `_prime_post_caches()` path after `the_posts` rather than its `update_post_caches()` + fast path. +- **`found_posts` is approximate** for loops that both paginate and exclude. +- **Deep pagination degrades**: page _P_ fetches `per_page * P + n` rows. Past the + ceiling the old SQL behaviour returns. + +Both knobs are filterable: + +```php +// Turn PHP-side exclusion off entirely. +add_filter( 'hm_query_loop_defer_exclusions', '__return_false' ); + +// Allow wider over-fetches (default 100). +add_filter( 'hm_query_loop_max_deferred_fetch', fn () => 250 ); +``` + +And for query presets, which would otherwise reach for `post__not_in`: + +```php +\HM\QueryLoop\QueryPresets\register_query_preset( + 'more_like_this', + 'More like this', + function ( $query_vars, $context ) { + // Instead of $query_vars['post__not_in'][] = $context['post_id']; + return \HM\QueryLoop\DeferredExclusions\add_exclusions( + $query_vars, + [ $context['post_id'] ] + ); + } +); +``` + +## Not done yet + +Ranked by what they would be worth. + +1. **Inherited queries.** `pre_render_block` re-runs the main query with modified + args, and exclusions there still go into `post__not_in`. The main query's key + is per-URL anyway, so the gain is smaller — but on an archive with several + loops it would still collapse several keys into one. The same + over-fetch-and-filter mechanism applies; the complication is that the main + query's paging is not built from core's Query Loop offset formula. + +2. **Sharing one result set between loops.** Two loops with the same query but + different `posts_per_page` currently produce two entries. Fetching the wider + one and slicing both from it in PHP would collapse them — the same trick used + for post templates, applied across sibling loops with a matching query + signature. + +3. **Quantising `posts_per_page`.** Rounding fetch sizes up to a step (5, 10, 20) + would make loops asking for 3, 4 and 5 posts share one entry, at the cost of + fetching a few rows nobody renders. + +4. **`no_found_rows` for loops with no pagination block.** Not a cache-key win, + but it removes the `SELECT FOUND_ROWS()` round trip. It changes the SQL, so it + must be applied consistently or it fragments the cache instead. + +5. **Avoiding needless tax queries.** A query with a `tax_query` mixes the + `terms` group's `last_changed` into its freshness check as well as `posts`, + so it is invalidated roughly twice as often. Presets that add an empty or + redundant tax query pay that for nothing. + +## Invalidation + +Worth knowing before spending much effort on key cardinality: **any post meta +write, on any post, invalidates every cached query on the site.** +`added_post_meta`, `updated_post_meta` and `deleted_post_meta` are all hooked to +`wp_cache_set_posts_last_changed()`, as is `clean_post_cache()`. On a site with +view counters, "last seen" stamps or similar per-request meta writes, the +`post-queries` group is being flushed constantly and no amount of key sharing +will help. + +Before optimising, measure: + +```php +add_action( 'wp_cache_set_last_changed', function ( $group, $time, $previous ) { + if ( 'posts' === $group && $previous ) { + error_log( 'posts last_changed bumped: ' . wp_debug_backtrace_summary() ); + } +}, 10, 3 ); +``` + +Two version differences also matter: + +- **WordPress 6.9** moved `last_changed` out of the cache key and into a salt + stored inside the cached value, so entries are now overwritten in place instead + of orphaned. Before 6.9, every invalidation left the whole previous generation + of keys stranded in the cache until the backend evicted them — so reducing key + cardinality bounds memory as well as improving the hit rate. +- **Before 6.6** `generate_cache_key()` did not `ksort()` the args, so the *order* + in which query vars were added changed the key. Building args conditionally + could fragment the cache on those versions for otherwise identical queries. + +## Things that are already fine + +- `fields` is stripped from the args and normalised out of the SQL before + hashing, so `fields => 'ids'` and a full-object query share one cache entry. +- `post__not_in` is sorted in place by core before the key is generated, so its + order never matters. (`post__in` is not, before 6.8 — sort it yourself if you + build one.) +- A query is not cached at all if its `ORDER BY` contains `RAND(`, or if a filter + changed `SELECT` to anything other than `{$wpdb->posts}.*`, + `{$wpdb->posts}.ID` or `{$wpdb->posts}.ID, {$wpdb->posts}.post_parent`. Worth + checking before concluding a query "should" be cached. + +## A caveat for the ElasticPress path + +`posts_pre_query` runs *before* the cache lookup but does not prevent the cache +*write*. A query short-circuited by ElasticPress therefore has its results stored +under a key derived from MySQL SQL that never ran, and later identical requests +serve those results from the object cache without consulting ElasticPress at all. +That is usually a performance win, but it means ElasticPress results are only as +fresh as the `posts` group's `last_changed` — worth an explicit decision on sites +where the index can change independently of WordPress content. diff --git a/hm-query-loop.php b/hm-query-loop.php index 3fa1656..906f90d 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -27,6 +27,9 @@ // Load query presets functionality. require_once HM_QUERY_LOOP_PATH . 'inc/query-presets.php'; +// Load PHP-side post exclusions. +require_once HM_QUERY_LOOP_PATH . 'inc/deferred-exclusions.php'; + /** * Initialize the plugin. */ @@ -42,7 +45,7 @@ function init() { add_filter( 'render_block', __NAMESPACE__ . '\\render_block', 11, 2 ); // Hook query_loop_block_query_vars to modify the query. - add_filter( 'query_loop_block_query_vars', __NAMESPACE__ . '\\filter_query_loop_block_query_vars', 11, 2 ); + add_filter( 'query_loop_block_query_vars', __NAMESPACE__ . '\\filter_query_loop_block_query_vars', 11, 3 ); // Hook into the_posts to track displayed posts and limit post-template posts. add_filter( 'the_posts', __NAMESPACE__ . '\\track_displayed_posts', 10, 2 ); @@ -52,6 +55,9 @@ function init() { // Initialize query presets functionality. QueryPresets\init(); + + // Initialize PHP-side post exclusions. + DeferredExclusions\init(); } add_action( 'init', __NAMESPACE__ . '\\init', 9 ); @@ -150,6 +156,19 @@ function filter_block_metadata( $metadata ) { */ $query_loop_post_template_per_pages = []; +/** + * The set of displayed post IDs each query loop excluded, keyed by query ID. + * + * A query loop renders several queries — one per post template, plus the ones + * the pagination and total blocks run — and each of those must exclude the same + * posts to produce the same SQL, and therefore share one cache entry. Taking the + * snapshot once per loop stops the set growing as the loop's own posts are + * tracked. + * + * @var array + */ +$query_loop_exclusion_snapshots = []; + /** * Get displayed post IDs. * @@ -383,52 +402,68 @@ function render_block( $block_content, $block ) { /** * Filter queries for loops that do not inherit from the main query. * - * @param array $query Query args for the query loop. + * @param array $query Query args for the query loop. * @param WP_Block $block Current block instance. + * @param int $page Current page of the loop. * @return array The modified query vars. */ -function filter_query_loop_block_query_vars( $query, WP_Block $block ) { - if ( $block->name === 'core/post-template' ) { - global $query_loop_post_template_per_pages; - - // Merge hmQueryLoop context with post template block attribute. - $attrs = $block->parsed_block['attrs']; - $attrs['hmQueryLoop'] = wp_parse_args( - $block->parsed_block['attrs']['hmQueryLoop'] ?? [], - $block->context['hmQueryLoop'] ?? [], - ); - $query_id = $block->context['queryId'] ?? 0; +function filter_query_loop_block_query_vars( $query, WP_Block $block, $page = 1 ) { + $query_id = $block->context['queryId'] ?? 0; - // Initialize tracking array for this query loop if not exists - if ( ! isset( $query_loop_post_template_per_pages[ $query_id ] ) ) { - $query_loop_post_template_per_pages[ $query_id ] = []; - } + $query = DeferredExclusions\track_loop( $query, $query_id ); + $query = modify_query_from_block_attrs( $query, $block->context, $query_id ); - // Get the query loop's total posts per page - $query_per_page = $query['posts_per_page'] ?? get_option( 'posts_per_page', 10 ); + if ( $block->name !== 'core/post-template' ) { + return $query; + } - // Calculate total posts used by preceding post templates - $used_posts = array_sum( $query_loop_post_template_per_pages[ $query_id ] ); + global $query_loop_post_template_per_pages; - // Get this post template's per page setting - $post_template_per_page = $attrs['hmQueryLoop']['perPage'] ?? null; + // Merge hmQueryLoop context with the post template's own attribute. + $settings = wp_parse_args( + $block->parsed_block['attrs']['hmQueryLoop'] ?? [], + $block->context['hmQueryLoop'] ?? [], + ); - // If no explicit perPage is set, calculate remaining posts - if ( empty( $post_template_per_page ) ) { - $remaining_posts = max( 1, $query_per_page - $used_posts ); - $post_template_per_page = $remaining_posts; + if ( ! isset( $query_loop_post_template_per_pages[ $query_id ] ) ) { + $query_loop_post_template_per_pages[ $query_id ] = []; + } - // Set it in attrs so it gets tracked - $attrs['hmQueryLoop']['perPage'] = $remaining_posts; - } + // How many posts the loop as a whole reads, as core worked it out. + $loop_per_page = (int) ( $query['posts_per_page'] ?? get_option( 'posts_per_page', 10 ) ); - // Track this post template's per page value - $query_loop_post_template_per_pages[ $query_id ][] = $post_template_per_page; + // Posts claimed by the post templates that already rendered in this loop. + $window_start = (int) array_sum( $query_loop_post_template_per_pages[ $query_id ] ); + + $window_size = $settings['perPage'] ?? null; + $window_size = empty( $window_size ) ? max( 1, $loop_per_page - $window_start ) : (int) $window_size; + + $query_loop_post_template_per_pages[ $query_id ][] = $window_size; + + // Every post template in the loop reads the same slice of the same result + // set and takes its own window out of it in PHP. Narrowing the query per + // template instead — with a smaller `posts_per_page` and the preceding + // templates' posts in `post__not_in` — gives each template SQL of its own, + // and so a cache entry of its own. + return DeferredExclusions\set_window( $query, $window_start, $window_size ); +} - $attrs['hmQueryLoop']['excludeDisplayedForCurrentLoop'] = $query_id; - return modify_query_from_block_attrs( $query, $attrs ); +/** + * Get the posts a query loop should exclude because an earlier loop showed them. + * + * Snapshotted per loop: see $query_loop_exclusion_snapshots. + * + * @param string|int $query_id The loop's query ID. + * @return array Post IDs. + */ +function get_loop_exclusions( $query_id ): array { + global $query_loop_exclusion_snapshots; + + if ( ! isset( $query_loop_exclusion_snapshots[ $query_id ] ) ) { + $query_loop_exclusion_snapshots[ $query_id ] = get_displayed_post_ids(); } - return modify_query_from_block_attrs( $query, $block->context ); + + return $query_loop_exclusion_snapshots[ $query_id ]; } /** @@ -463,27 +498,37 @@ function exclude_posts_from_query( $query, $excluded_ids ) { } /** - * Modify query using pre_get_posts based on block attributes. - * This is hooked/unhooked dynamically around Query Loop block rendering. + * Modify query args based on block attributes. * - * @param array $query The query args array. - * @param array $attrs The block attributes/context. + * @param array $query The query args array. + * @param array $attrs The block attributes/context. + * @param string|int|null $query_id The loop's query ID, or null for the main + * query, where exclusions cannot be deferred. * @return array Modified query args. */ -function modify_query_from_block_attrs( $query = [], $attrs = [] ) { +function modify_query_from_block_attrs( $query = [], $attrs = [], $query_id = null ) { global $original_paged; // Get the hmQueryLoop settings object. $settings = $attrs['hmQueryLoop'] ?? []; - // Start collecting post IDs. - $query['hm_query_loop_collect_ids'] = true; + $is_deferred = null !== $query_id; - // If hiding on paginated URLs force the page to page 1. - if ( isset( $settings['hideOnPaged'] ) && $settings['hideOnPaged'] ) { - $query['paged'] = 1; - } else { - $query['paged'] = $original_paged; + if ( ! $is_deferred ) { + // Start collecting post IDs. Loops that go through track_loop() are + // flagged out of band instead, so this never reaches the cache key. + $query['hm_query_loop_collect_ids'] = true; + } + + // If hiding on paginated URLs force the page to page 1. `offset` takes + // precedence over `paged` in the LIMIT clause, so only set it where it can + // actually change the result — otherwise it is dead weight in the cache key. + if ( ! isset( $query['offset'] ) ) { + if ( ! empty( $settings['hideOnPaged'] ) ) { + $query['paged'] = 1; + } elseif ( ! empty( $original_paged ) ) { + $query['paged'] = $original_paged; + } } // Apply custom posts per page if set and is a valid number. @@ -496,20 +541,14 @@ function modify_query_from_block_attrs( $query = [], $attrs = [] ) { $query['ep_integrate'] = true; } - // Exclude already displayed posts if enabled. - if ( isset( $settings['excludeDisplayed'] ) && $settings['excludeDisplayed'] ) { - $displayed_ids = get_displayed_post_ids(); - if ( ! empty( $displayed_ids ) ) { - $query = exclude_posts_from_query( $query, $displayed_ids ); - } - } + // Exclude posts shown by earlier query loops on this page. + if ( ! empty( $settings['excludeDisplayed'] ) ) { + $displayed_ids = $is_deferred ? get_loop_exclusions( $query_id ) : get_displayed_post_ids(); - // Exclude already displayed posts for this loop if enabled. - if ( isset( $settings['excludeDisplayedForCurrentLoop'] ) ) { - $query['query_id'] = $settings['excludeDisplayedForCurrentLoop']; - $displayed_ids = get_query_loop_used_posts( $settings['excludeDisplayedForCurrentLoop'] ); if ( ! empty( $displayed_ids ) ) { - $query = exclude_posts_from_query( $query, $displayed_ids ); + $query = $is_deferred + ? DeferredExclusions\add_exclusions( $query, $displayed_ids ) + : exclude_posts_from_query( $query, $displayed_ids ); } } @@ -524,16 +563,28 @@ function modify_query_from_block_attrs( $query = [], $attrs = [] ) { * @return array Array of post objects. */ function track_displayed_posts( $posts, $query ) { - if ( ! $query->get( 'hm_query_loop_collect_ids' ) ) { + if ( ! $query instanceof WP_Query ) { return $posts; } - // Track posts from query loops (either approach) or the main query. + // Query loop blocks are flagged out of band so that nothing this plugin + // adds ends up in the query's cache key; the main query still uses a query + // var, which is unique to that request anyway. + $context = DeferredExclusions\get_context( $query ); + + if ( null === $context && ! $query->get( 'hm_query_loop_collect_ids' ) ) { + return $posts; + } + + // Runs at priority 10, after DeferredExclusions\filter_posts() has trimmed + // the over-fetched result set, so only posts that really render are tracked. if ( ! empty( $posts ) ) { $post_ids = wp_list_pluck( $posts, 'ID' ); add_displayed_post_ids( $post_ids ); - add_query_loop_used_posts( $query->get( 'query_id', -1 ), $post_ids ); + add_query_loop_used_posts( $context['query_id'] ?? $query->get( 'query_id', -1 ), $post_ids ); } + DeferredExclusions\release_context( $query ); + return $posts; } diff --git a/inc/deferred-exclusions.php b/inc/deferred-exclusions.php new file mode 100644 index 0000000..2cdd227 --- /dev/null +++ b/inc/deferred-exclusions.php @@ -0,0 +1,478 @@ + + */ +$bound_contexts = []; + +/** + * Register hooks. + * + * @return void + */ +function init(): void { + // Run after every other `query_loop_block_query_vars` filter — this plugin's + // own (priority 11) and query presets (15) — so the fetch size is planned + // against the final query vars. + add_filter( 'query_loop_block_query_vars', __NAMESPACE__ . '\\plan_query', 999, 3 ); + + // Move the state off the query vars before the cache key is generated. + add_action( 'pre_get_posts', __NAMESPACE__ . '\\bind_context', 0 ); + + // Apply the plan before HM\QueryLoop\track_displayed_posts() (priority 10) + // records the IDs, so only posts that are really rendered get tracked. + add_filter( 'the_posts', __NAMESPACE__ . '\\filter_posts', 9, 2 ); +} + +/** + * Whether exclusions should be deferred to PHP rather than pushed into SQL. + * + * @return bool + */ +function is_enabled(): bool { + /** + * Filters whether Query Loop exclusions are applied in PHP after querying. + * + * Disabling this restores `post__not_in` based exclusion, at the cost of a + * separate object cache entry per set of excluded IDs. + * + * @param bool $enabled Whether to defer exclusions to PHP. Default true. + */ + return (bool) apply_filters( 'hm_query_loop_defer_exclusions', true ); +} + +/** + * The largest number of posts a loop may fetch in order to defer an exclusion. + * + * Deferring trades a slightly wider result set for a reusable cache entry. Past + * some size that trade stops paying off, and the query falls back to excluding + * in SQL. + * + * @return int + */ +function get_max_fetch(): int { + /** + * Filters the maximum number of posts fetched to satisfy a deferred exclusion. + * + * @param int $max_fetch Maximum posts to fetch. Default 100. + */ + return max( 1, (int) apply_filters( 'hm_query_loop_max_deferred_fetch', 100 ) ); +} + +/** + * Record post IDs for a Query Loop to exclude in PHP after querying. + * + * Use this from a `query_loop_block_query_vars` filter or a query preset instead + * of adding to `post__not_in`, whenever the IDs vary from one URL to the next. + * + * @param array $query Query vars for the loop. + * @param array $exclude_ids Post IDs to exclude. + * @return array Modified query vars. + */ +function add_exclusions( array $query, array $exclude_ids ): array { + $exclude_ids = array_filter( array_map( 'intval', $exclude_ids ) ); + + if ( empty( $exclude_ids ) ) { + return $query; + } + + $existing = $query[ QUERY_VAR ]['exclude'] ?? []; + + $query[ QUERY_VAR ]['exclude'] = array_values( array_unique( array_merge( $existing, $exclude_ids ) ) ); + + return $query; +} + +/** + * Mark a set of query vars as belonging to a query loop, for post tracking. + * + * The loop's ID is kept out of the query vars proper because it is derived from + * the post being viewed, so passing it to `WP_Query` would give every loop a + * cache key of its own on every URL it renders on. + * + * @param array $query Query vars for the loop. + * @param string|int $query_id The loop's `queryId`. + * @return array Modified query vars. + */ +function track_loop( array $query, $query_id = -1 ): array { + $query[ QUERY_VAR ]['query_id'] = $query_id; + + return $query; +} + +/** + * Render only part of a loop's result set, without narrowing the query. + * + * Used by the multiple post templates feature: each template reads the same + * result set and takes a different window out of it, so they all issue the same + * query and share one cache entry. + * + * @param array $query Query vars for the loop. + * @param int $start Posts to skip, relative to the loop's own page of results. + * @param int $size Posts to render. + * @return array Modified query vars. + */ +function set_window( array $query, int $start, int $size ): array { + $query[ QUERY_VAR ]['window_start'] = max( 0, $start ); + $query[ QUERY_VAR ]['window_size'] = $size; + + return $query; +} + +/** + * Turn the recorded exclusions into a fetch plan, or fall back to SQL exclusion. + * + * @param array $query Query vars for the loop. + * @param WP_Block $block The `core/post-template` block being rendered. + * @param int $page Current page of the loop. + * @return array Modified query vars. + */ +function plan_query( $query, $block, $page = 1 ) { + if ( ! is_array( $query ) ) { + return $query; + } + + $context = $query[ QUERY_VAR ] ?? []; + $exclude = $context['exclude'] ?? []; + unset( $context['exclude'] ); + + // Core applies the block's own "exclude current post" setting by adding the + // post ID to `post__not_in`, which is the single biggest source of per-URL + // cache keys. Take it over. + if ( $block instanceof WP_Block && ! empty( $block->context['query']['excludeCurrent'] ) ) { + $current_post_id = get_the_ID(); + if ( $current_post_id ) { + $exclude[] = (int) $current_post_id; + } + } + + /** + * Filters the post IDs a Query Loop should exclude in PHP after querying. + * + * @param int[] $exclude Post IDs to exclude. + * @param array $query Query vars for the loop. + * @param WP_Block|null $block The post template block being rendered. + * @param int $page Current page of the loop. + */ + $exclude = apply_filters( + 'hm_query_loop_deferred_exclusions', + $exclude, + $query, + $block instanceof WP_Block ? $block : null, + (int) $page + ); + + $exclude = array_values( array_unique( array_filter( array_map( 'intval', (array) $exclude ) ) ) ); + + $plan = build_plan( $query, $exclude, max( 1, (int) $page ), $context ); + + // Whatever the plan could not take on has to narrow the query after all. + $in_sql = array_values( array_diff( $exclude, $plan['exclude'] ) ); + + if ( ! empty( $in_sql ) ) { + $query = \HM\QueryLoop\exclude_posts_from_query( $query, $in_sql ); + } + + if ( ! empty( $plan['exclude'] ) && ! empty( $query['post__not_in'] ) && is_array( $query['post__not_in'] ) ) { + // These are handled after the query runs, so they must not narrow it. + $query['post__not_in'] = array_values( array_diff( $query['post__not_in'], $plan['exclude'] ) ); + } + + if ( $plan['needed'] ) { + $query['posts_per_page'] = $plan['fetch']; + + // Only introduce an `offset` where the query already had one, or where it + // is actually needed: `offset` overrides `paged` in the LIMIT clause. + if ( isset( $query['offset'] ) || $plan['fetch_offset'] > 0 ) { + $query['offset'] = $plan['fetch_offset']; + } + + $context['plan'] = $plan; + } elseif ( $plan['window_size'] !== $plan['loop_per_page'] || $plan['window_start'] > 0 ) { + // The window cannot be applied in PHP — see build_plan() — so narrow the + // query to it instead, giving each post template a query of its own. + $query['posts_per_page'] = $plan['window_size']; + $query['offset'] = $plan['fetch_offset'] + $plan['window_start']; + } + + unset( $context['window_start'], $context['window_size'] ); + + if ( empty( $context ) ) { + unset( $query[ QUERY_VAR ] ); + } else { + $query[ QUERY_VAR ] = $context; + } + + return $query; +} + +/** + * Whether the results of this query will still pass through `the_posts`. + * + * `WP_Query::get_posts()` returns early — before `posts_results` and + * `the_posts` — when only post IDs were asked for, and skips those filters + * entirely when filters are suppressed. Nothing can be done in PHP afterwards + * for such a query. + * + * @param array $query Query vars for the loop. + * @return bool + */ +function can_filter_results( array $query ): bool { + if ( ! empty( $query['suppress_filters'] ) ) { + return false; + } + + return ! in_array( $query['fields'] ?? '', [ 'ids', 'id=>parent' ], true ); +} + +/** + * Work out what to fetch so the loop can be assembled in PHP afterwards. + * + * Over-fetching by `count( $exclude )` guarantees a full page after filtering: + * at most one fetched post can be dropped per excluded ID, so at least as many + * survive as the loop asked for. + * + * @param array $query Query vars for the loop. + * @param int[] $exclude Post IDs to exclude. + * @param int $page Current page of the loop. + * @param array $context Recorded per-loop state. + * @return array The plan. + */ +function build_plan( array $query, array $exclude, int $page, array $context ): array { + $loop_per_page = isset( $query['posts_per_page'] ) + ? (int) $query['posts_per_page'] + : (int) get_option( 'posts_per_page', 10 ); + + $window_start = (int) ( $context['window_start'] ?? 0 ); + $window_size = (int) ( $context['window_size'] ?? $loop_per_page ); + + $plan = [ + 'exclude' => [], + 'loop_per_page' => $loop_per_page, + 'window_start' => $window_start, + 'window_size' => $window_size, + 'window_offset' => $window_start, + 'fetch_offset' => (int) ( $query['offset'] ?? 0 ), + 'fetch' => $loop_per_page, + 'needed' => false, + ]; + + // Without an explicit `posts_per_page` the loop pages through `paged` rather + // than the `offset` core normally computes, and the arithmetic below no + // longer describes what the query will return. + if ( ! can_filter_results( $query ) || ! isset( $query['posts_per_page'] ) ) { + return $plan; + } + + // An unbounded loop already reads every matching post, so there is nothing + // to over-fetch — filtering and windowing the result set is enough. + if ( $loop_per_page < 1 ) { + $plan['exclude'] = $exclude; + $plan['needed'] = ! empty( $exclude ) || $window_start > 0 || $window_size !== $loop_per_page; + + return $plan; + } + + if ( ! empty( $exclude ) && is_enabled() ) { + // Core sets `offset` to the paging offset plus any offset configured on + // the block. Recover the configured part, so that paging — which the + // exclusions shift — can be redone in PHP. + $base_offset = $plan['fetch_offset'] - ( $loop_per_page * ( $page - 1 ) ); + $fetch = ( $loop_per_page * $page ) + count( $exclude ); + + // A negative base offset means the offset was not built by core's + // formula, and re-slicing would silently move the window. Past + // get_max_fetch() the over-fetch costs more than the shared cache entry + // is worth. Either way, leave these exclusions to SQL. + if ( $base_offset >= 0 && $fetch <= get_max_fetch() ) { + $plan['exclude'] = $exclude; + $plan['fetch'] = $fetch; + $plan['fetch_offset'] = $base_offset; + $plan['window_offset'] = ( $loop_per_page * ( $page - 1 ) ) + $window_start; + $plan['needed'] = true; + + return $plan; + } + } + + // No deferred exclusions: fetch the loop's own page, and window it. Post + // templates that overrun the loop's page size need the extra posts. + $plan['fetch'] = max( $loop_per_page, $window_start + $window_size ); + $plan['needed'] = $window_start > 0 || $window_size !== $plan['fetch']; + + return $plan; +} + +/** + * Move the per-loop state off the query vars and onto the WP_Query instance. + * + * `WP_Query::generate_cache_key()` hashes the query vars, so leaving the state + * there would defeat the point of the exercise. `pre_get_posts` fires before the + * key is generated. + * + * @param WP_Query $query The query being prepared. + * @return void + */ +function bind_context( $query ): void { + if ( ! $query instanceof WP_Query ) { + return; + } + + $context = $query->get( QUERY_VAR ); + + if ( empty( $context ) || ! is_array( $context ) ) { + return; + } + + global $bound_contexts; + + $bound_contexts[ spl_object_id( $query ) ] = $context; + + unset( $query->query_vars[ QUERY_VAR ] ); + + if ( is_array( $query->query ) ) { + unset( $query->query[ QUERY_VAR ] ); + } +} + +/** + * Get the per-loop state bound to a query, if any. + * + * @param WP_Query $query The query instance. + * @return array|null + */ +function get_context( WP_Query $query ): ?array { + global $bound_contexts; + + return $bound_contexts[ spl_object_id( $query ) ] ?? null; +} + +/** + * Forget the state bound to a query once its results have been dealt with. + * + * @param WP_Query $query The query instance. + * @return void + */ +function release_context( WP_Query $query ): void { + global $bound_contexts; + + unset( $bound_contexts[ spl_object_id( $query ) ] ); +} + +/** + * Drop excluded posts and re-apply paging in PHP. + * + * @param array $posts Posts returned by the query. + * @param WP_Query $query The query instance. + * @return array Filtered posts. + */ +function filter_posts( $posts, $query ) { + if ( ! $query instanceof WP_Query || ! is_array( $posts ) ) { + return $posts; + } + + $context = get_context( $query ); + $plan = $context['plan'] ?? null; + + if ( null === $plan ) { + return $posts; + } + + $result = apply_plan( $posts, $plan ); + + // `found_posts` and `max_num_pages` were derived from the unfiltered count. + // Bring them down by however many posts the filtering removed, so pagination + // does not offer a page with nothing left on it. Only the fetched window is + // visible here, so this is a lower bound on the real overcount. + // + // This has to happen on every request rather than through the `found_posts` + // filter: that filter only runs on a cache miss, and its result is stored in + // the shared cache entry, which must stay unadjusted. + if ( $query->found_posts > 0 && $result['removed'] > 0 ) { + $query->found_posts = max( 0, (int) $query->found_posts - $result['removed'] ); + + if ( $plan['loop_per_page'] > 0 ) { + $query->max_num_pages = (int) ceil( $query->found_posts / $plan['loop_per_page'] ); + } + } + + // Report the number of posts the loop asked for rather than the number + // fetched to satisfy the plan; core/query-total reads this back. + if ( $plan['loop_per_page'] > 0 ) { + $query->query_vars['posts_per_page'] = $plan['loop_per_page']; + } + + $query->post_count = count( $result['posts'] ); + + return $result['posts']; +} + +/** + * Apply an exclusion plan to a list of posts. + * + * Kept free of WordPress state so the slicing rules can be reasoned about — and + * tested — on their own. + * + * @param array $posts Posts (or post IDs) returned by the query. + * @param array $plan Plan as built by build_plan(). + * @return array{posts: array, removed: int} The posts to render, and how many were dropped. + */ +function apply_plan( array $posts, array $plan ): array { + $lookup = array_flip( $plan['exclude'] ); + + $kept = []; + foreach ( $posts as $post ) { + $id = is_object( $post ) ? (int) $post->ID : (int) $post; + if ( ! isset( $lookup[ $id ] ) ) { + $kept[] = $post; + } + } + + $removed = count( $posts ) - count( $kept ); + + return [ + 'posts' => array_slice( + $kept, + $plan['window_offset'], + $plan['window_size'] > 0 ? $plan['window_size'] : null + ), + 'removed' => $removed, + ]; +} diff --git a/package.json b/package.json index e26c490..dfec269 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint:css:fix": "wp-scripts lint-style --fix", "format": "wp-scripts format", "wp-env": "wp-env", + "test:php": "php tests/php/deferred-exclusions-test.php", "test:e2e": "wp-scripts test-playwright", "test:e2e:debug": "wp-scripts test-playwright --debug", "test:e2e:watch": "wp-scripts test-playwright --watch" diff --git a/tests/php/deferred-exclusions-test.php b/tests/php/deferred-exclusions-test.php new file mode 100644 index 0000000..6249d58 --- /dev/null +++ b/tests/php/deferred-exclusions-test.php @@ -0,0 +1,390 @@ + [] ]; + + public function __construct( $name, $context = [] ) { + $this->name = $name; + $this->context = $context; + } + } + + class WP_Query { + public $query_vars = []; + public $query = []; + public $post_count = 0; + public $found_posts = 0; + public $max_num_pages = 0; + + public function __construct( $args = [] ) { + $this->query = $args; + $this->query_vars = $args; + } + + public function get( $key, $default = '' ) { + return $this->query_vars[ $key ] ?? $default; + } + } + + function apply_filters( $tag, $value, ...$args ) { + return $value; + } + + function add_filter( ...$args ) {} + + function add_action( ...$args ) {} + + function get_option( $name, $default = false ) { + return $default; + } + + function get_the_ID() { + return $GLOBALS['current_post_id']; + } +} + +namespace HM\QueryLoop { + + /** + * Copy of the real function; it lives in the main plugin file, which cannot + * be loaded without WordPress. + * + * @param array $query Query args. + * @param array $excluded_ids Post IDs to exclude. + * @return array + */ + function exclude_posts_from_query( $query, $excluded_ids ) { + if ( ! empty( $query['post__in'] ) && is_array( $query['post__in'] ) ) { + $query['post__in'] = array_values( array_diff( $query['post__in'], $excluded_ids ) ); + } + + $query['post__not_in'] = array_values( + array_unique( array_merge( $query['post__not_in'] ?? [], $excluded_ids ) ) + ); + + return $query; + } +} + +namespace HM\QueryLoop\Tests { + + use WP_Block; + use WP_Query; + + use function HM\QueryLoop\DeferredExclusions\add_exclusions; + use function HM\QueryLoop\DeferredExclusions\bind_context; + use function HM\QueryLoop\DeferredExclusions\filter_posts; + use function HM\QueryLoop\DeferredExclusions\get_context; + use function HM\QueryLoop\DeferredExclusions\plan_query; + use function HM\QueryLoop\DeferredExclusions\set_window; + use function HM\QueryLoop\DeferredExclusions\track_loop; + + use const HM\QueryLoop\DeferredExclusions\QUERY_VAR; + + require_once dirname( __DIR__, 2 ) . '/inc/deferred-exclusions.php'; + + $failures = 0; + $checks = 0; + + /** + * Assert that two values match. + * + * @param string $label What is being checked. + * @param mixed $actual The value produced. + * @param mixed $expected The value wanted. + * @return void + */ + function check( string $label, $actual, $expected ): void { + global $failures, $checks; + + $checks++; + + if ( $actual === $expected ) { + printf( "ok %s\n", $label ); + return; + } + + $failures++; + printf( + "FAIL %s\n expected: %s\n actual: %s\n", + $label, + wp_json_encode_fallback( $expected ), + wp_json_encode_fallback( $actual ) + ); + } + + /** + * Render a value for failure output. + * + * @param mixed $value Value to render. + * @return string + */ + function wp_json_encode_fallback( $value ): string { + return (string) json_encode( $value ); + } + + /** + * Plan a loop's query, run it against a fake database, and apply the plan. + * + * @param array $query Query vars as core would have built them. + * @param WP_Block $block The block being rendered. + * @param int $page Page of the loop. + * @param int[] $universe Every post ID matching the query, in order. + * @return array{posts: int[], sql: array, query: WP_Query} + */ + function run_loop( array $query, WP_Block $block, int $page, array $universe ): array { + $planned = plan_query( $query, $block, $page ); + + $wp_query = new WP_Query( $planned ); + bind_context( $wp_query ); + + // Stand in for the SQL: apply the exclusions, then the LIMIT clause. + $rows = $universe; + if ( ! empty( $planned['post__not_in'] ) ) { + $rows = array_values( array_diff( $rows, $planned['post__not_in'] ) ); + } + + $offset = (int) ( $planned['offset'] ?? 0 ); + $per_page = (int) ( $planned['posts_per_page'] ?? 10 ); + $results = $per_page < 0 + ? array_slice( $rows, $offset ) + : array_slice( $rows, $offset, $per_page ); + + $wp_query->found_posts = count( $rows ); + $wp_query->max_num_pages = $per_page > 0 ? (int) ceil( count( $rows ) / $per_page ) : 1; + + return [ + 'posts' => filter_posts( $results, $wp_query ), + 'sql' => [ + 'posts_per_page' => $planned['posts_per_page'] ?? null, + 'offset' => $planned['offset'] ?? null, + 'post__not_in' => $planned['post__not_in'] ?? [], + ], + 'query' => $wp_query, + ]; + } + + $universe = range( 101, 130 ); + $template = new WP_Block( 'core/post-template' ); + + // The headline case: the latest 5 posts, minus the post being viewed. + $excludes_current = new WP_Block( 'core/post-template', [ 'query' => [ 'excludeCurrent' => true ] ] ); + $GLOBALS['current_post_id'] = 103; + + $on_103 = run_loop( + [ + 'posts_per_page' => 5, + 'offset' => 0, + 'post__not_in' => [ 103 ], + ], + $excludes_current, + 1, + $universe + ); + + check( 'excludeCurrent: nothing excluded in SQL', $on_103['sql']['post__not_in'], [] ); + check( 'excludeCurrent: over-fetches by one', $on_103['sql']['posts_per_page'], 6 ); + check( 'excludeCurrent: renders 5, without the current post', $on_103['posts'], [ 101, 102, 104, 105, 106 ] ); + + $GLOBALS['current_post_id'] = 107; + + $on_107 = run_loop( + [ + 'posts_per_page' => 5, + 'offset' => 0, + 'post__not_in' => [ 107 ], + ], + $excludes_current, + 1, + $universe + ); + + // This is the point of the exercise: one cached result set serves every URL. + check( 'excludeCurrent: same query on a different URL', $on_107['sql'], $on_103['sql'] ); + check( 'excludeCurrent: different URL renders differently', $on_107['posts'], [ 101, 102, 103, 104, 105 ] ); + + $GLOBALS['current_post_id'] = 0; + + // Excluding posts an earlier loop on the page already showed. + $second_loop = run_loop( + add_exclusions( + [ + 'posts_per_page' => 5, + 'offset' => 0, + 'post__not_in' => [], + ], + [ 101, 102, 103 ] + ), + $template, + 1, + $universe + ); + + check( 'excludeDisplayed: fetches 5 + 3', $second_loop['sql']['posts_per_page'], 8 ); + check( 'excludeDisplayed: nothing excluded in SQL', $second_loop['sql']['post__not_in'], [] ); + check( 'excludeDisplayed: still a full page', $second_loop['posts'], [ 104, 105, 106, 107, 108 ] ); + check( 'excludeDisplayed: found_posts adjusted', $second_loop['query']->found_posts, 27 ); + + // The same, on the loop's second page. + $page_two = run_loop( + add_exclusions( + [ + 'posts_per_page' => 5, + 'offset' => 5, + 'post__not_in' => [], + ], + [ 101, 102, 103 ] + ), + $template, + 2, + $universe + ); + + check( 'page 2: reads from the start of the result set', $page_two['sql']['offset'], 0 ); + check( 'page 2: fetches 5 * 2 + 3', $page_two['sql']['posts_per_page'], 13 ); + check( 'page 2: carries on where page 1 stopped', $page_two['posts'], [ 109, 110, 111, 112, 113 ] ); + + // Two post templates dividing one loop between them. + $windows = []; + foreach ( [ [ 0, 2 ], [ 2, 4 ] ] as $window ) { + $windows[] = run_loop( + set_window( + [ + 'posts_per_page' => 6, + 'offset' => 0, + 'post__not_in' => [], + ], + $window[0], + $window[1] + ), + $template, + 1, + $universe + ); + } + + check( 'post templates: first window', $windows[0]['posts'], [ 101, 102 ] ); + check( 'post templates: second window', $windows[1]['posts'], [ 103, 104, 105, 106 ] ); + check( 'post templates: both issue one query', $windows[0]['sql'], $windows[1]['sql'] ); + check( 'post templates: which is the loop\'s own', $windows[0]['sql']['posts_per_page'], 6 ); + + // Windows and exclusions together. + $combined = []; + foreach ( [ [ 0, 2 ], [ 2, 4 ] ] as $window ) { + $query = add_exclusions( + [ + 'posts_per_page' => 6, + 'offset' => 0, + 'post__not_in' => [], + ], + [ 102, 105 ] + ); + + $combined[] = run_loop( set_window( $query, $window[0], $window[1] ), $template, 1, $universe ); + } + + check( 'windows + exclusions: first window', $combined[0]['posts'], [ 101, 103 ] ); + check( 'windows + exclusions: second window', $combined[1]['posts'], [ 104, 106, 107, 108 ] ); + check( 'windows + exclusions: still one query', $combined[0]['sql'], $combined[1]['sql'] ); + + // Past the over-fetch cap the trade stops paying off. + $too_wide = run_loop( + add_exclusions( + [ + 'posts_per_page' => 60, + 'offset' => 0, + 'post__not_in' => [], + ], + range( 101, 150 ) + ), + $template, + 1, + $universe + ); + + check( 'over the cap: excludes in SQL instead', $too_wide['sql']['post__not_in'], range( 101, 150 ) ); + check( 'over the cap: leaves posts_per_page alone', $too_wide['sql']['posts_per_page'], 60 ); + + // An ID-only query never reaches `the_posts`, so nothing can be done in PHP. + $ids_only = plan_query( + add_exclusions( + [ + 'posts_per_page' => 5, + 'offset' => 0, + 'fields' => 'ids', + 'post__not_in' => [], + ], + [ 101 ] + ), + $template, + 1 + ); + + check( 'fields=ids: excludes in SQL', $ids_only['post__not_in'], [ 101 ] ); + check( 'fields=ids: does not over-fetch', $ids_only['posts_per_page'], 5 ); + + // A loop with nothing to exclude and one template is left exactly as it was. + $untouched = plan_query( + track_loop( + [ + 'posts_per_page' => 5, + 'offset' => 0, + 'post__not_in' => [], + ], + 42 + ), + $template, + 1 + ); + unset( $untouched[ QUERY_VAR ] ); + + check( + 'plain loop: query vars untouched', + $untouched, + [ + 'posts_per_page' => 5, + 'offset' => 0, + 'post__not_in' => [], + ] + ); + + // Nothing this plugin tracks may reach the vars WP_Query hashes into its key. + $tracked = track_loop( [ 'posts_per_page' => 5, 'offset' => 0 ], 9001 ); + $tracked = plan_query( add_exclusions( $tracked, [ 110 ] ), $template, 1 ); + $wp_query = new WP_Query( $tracked ); + bind_context( $wp_query ); + + check( 'cache key: no plugin state in query_vars', array_keys( $wp_query->query_vars ), [ 'posts_per_page', 'offset' ] ); + check( 'cache key: no plugin state in query', array_keys( $wp_query->query ), [ 'posts_per_page', 'offset' ] ); + check( 'loop identity bound to the query instead', get_context( $wp_query )['query_id'], 9001 ); + + printf( "\n%d checks, %d failures\n", $checks, $failures ); + + exit( $failures > 0 ? 1 : 0 ); +} From dafbabba23642643203fc0a249d0dc55d2d88a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 09:11:00 +0000 Subject: [PATCH 02/12] Record benchmark results and correct the excludeCurrent claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmarked the branch against b9f3925 on WP 6.9 / PHP 8.4 / MariaDB with 601 posts, rendering block fixtures across many URLs with the object cache persisting between them. The headline result is not the one the docs led with. A single query loop with no plugin settings at all — nothing for the over-fetching to do — still goes from 306 database queries to 108 across 100 URLs, because the gain there is entirely from no longer putting `query_id` in the query vars. Three distinct queries were being re-executed 201 times purely because their cache keys differed by post ID. Since `query_id` was set for every post-template query, every query loop the plugin touched had a private cache entry on every URL it rendered on. On a 12-loop page across 40 URLs: 1263 database queries to 72, and 50.4 to 34.5 ms per URL, with output identical post for post. A cold render against an empty cache — the worst case for over-fetching — is within noise at 74.9 vs 73.8 ms, and still drops 87 queries to 66. The admin editor is unaffected in every measurement, which is what the code predicts: the built editor bundle is byte-identical between the two builds and `query_loop_block_query_vars` does not fire in admin. Also corrects a claim these docs made: `excludeCurrent` is not in WordPress 6.9, only in core trunk. On 6.9 and earlier core ignores the attribute, so this plugin is not "taking it over" there — it is what makes the setting do anything at all. That is a behaviour change on those versions and is now called out as one; the benchmark shows it changing the rendered posts on 5 of 25 URLs, exactly those where the current post fell inside the window. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- CLAUDE.md | 2 +- README.md | 2 +- docs/query-caching.md | 74 +++++++++++++++++++++++++++++++++++-- inc/deferred-exclusions.php | 8 ++-- 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 73d7337..285ec33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ The plugin handles two different query scenarios: `post__not_in` puts excluded IDs into the SQL, and `WP_Query` derives its `post-queries` cache key from the SQL, so a loop excluding the post being viewed gets a private cache entry on every URL. For non-inherited queries the plugin instead over-fetches by `count( $exclude )` and drops the posts in PHP on `the_posts` — which core runs *after* writing the result to the object cache, so the shareable superset is what gets cached. - `plan_query()` (`query_loop_block_query_vars`, priority 999 — after presets) turns recorded exclusions and post-template windows into a fetch plan -- Sources: core's `query.excludeCurrent`, the plugin's `excludeDisplayed`, and the `hm_query_loop_deferred_exclusions` filter +- Sources: the `query.excludeCurrent` block attribute (implemented by core only after 6.9 — on 6.9 and earlier this plugin is what makes it do anything), the plugin's `excludeDisplayed`, and the `hm_query_loop_deferred_exclusions` filter - `bind_context()` (`pre_get_posts`, priority 0) strips the plugin's state from the query vars before the cache key is generated, binding it to the `WP_Query` instance instead. **Any** custom query var reaches the cache key, so nothing this plugin tracks may be left in there - `filter_posts()` (`the_posts`, priority 9) applies the plan and corrects `found_posts`/`max_num_pages`; it runs before post tracking at priority 10 - Falls back to SQL exclusion when the fetch would exceed `hm_query_loop_max_deferred_fetch` (default 100), when `hm_query_loop_defer_exclusions` is false, or when the query cannot reach `the_posts` (`fields => ids`, `suppress_filters`) diff --git a/README.md b/README.md index 719f203..f8b8bf6 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Register custom query configurations in PHP that can be selected from a dropdown Excluding posts with `post__not_in` gives every URL its own `WP_Query` cache entry, because the excluded IDs end up in the SQL the cache key is built from. For non-inherited query loops this plugin fetches a few extra posts instead and drops the unwanted ones in PHP, so "the latest 5 posts, excluding this one" is one cached query shared by every post on the site rather than one per post. -This applies to the plugin's own exclusion setting, to core's `excludeCurrent` block attribute, and to anything added through the `hm_query_loop_deferred_exclusions` filter. See [Query caching](docs/query-caching.md) for the details and the trade-offs. +This applies to the plugin's own exclusion setting, to the `excludeCurrent` block attribute, and to anything added through the `hm_query_loop_deferred_exclusions` filter. Note that WordPress 6.9 and earlier ignore `excludeCurrent` entirely, so on those versions this plugin implements the setting rather than merely making it cacheable. See [Query caching](docs/query-caching.md) for the details and the trade-offs. ### 8. Sticky Posts diff --git a/docs/query-caching.md b/docs/query-caching.md index 6a6ccf8..dc7a542 100644 --- a/docs/query-caching.md +++ b/docs/query-caching.md @@ -101,9 +101,16 @@ Three sources feed it: | This plugin's **Exclude already displayed posts** setting | Position on the page | | The `hm_query_loop_deferred_exclusions` filter | Whatever you want | -Core applies `excludeCurrent` itself, in `build_query_vars_from_query_block()`, -by appending `get_the_ID()` to `post__not_in`. The plugin takes that ID back out -and handles it in PHP, so no configuration is needed to get the improvement. +`excludeCurrent` needs a word of explanation, because what it does depends on +the WordPress version. Core trunk applies it in +`build_query_vars_from_query_block()` by appending `get_the_ID()` to +`post__not_in`; the plugin takes that ID back out and handles it in PHP, so no +configuration is needed to get the improvement. **WordPress 6.9 and earlier have +no `excludeCurrent` support at all** — core ignores the attribute. There the +plugin does not take anything over, it implements the setting: a loop whose +block attributes carry `excludeCurrent` starts excluding the current post where +previously the attribute did nothing. That is the intended behaviour of the +setting, but it is a behaviour change on those versions, not just a caching one. ### 2. Post templates share one query @@ -190,6 +197,67 @@ And for query presets, which would otherwise reach for `post__not_in`: ); ``` +## Measured + +Benchmarked against the commit this branch merges (`b9f3925`) on WordPress 6.9, +PHP 8.4, MariaDB on the same host, Twenty Twenty-Five, 601 posts across six +categories. Each "URL" is a full block render with the object cache persisting +between renders, which is what a persistent object cache does between requests +on a site whose content is not being edited. + +**A magazine-style page — 12 query loops, two of them split across multiple post +templates, most excluding what earlier loops showed — rendered on 40 URLs:** + +| | before | after | +|---|---|---| +| Database queries | 1263 | **72** | +| Query-loop `SELECT`s executed | 1201 | **23** | +| Render time | 50.4 ms/URL | **34.5 ms/URL** | +| Posts rendered | 2720 | 2720 (identical, post for post) | + +**A single query loop with no plugin settings at all, rendered on 100 URLs:** + +| | before | after | +|---|---|---| +| Database queries | 306 | **108** | +| Query-loop `SELECT`s executed | 201 | **3** | +| Render time | 4.38 ms/URL | **3.34 ms/URL** | + +That second table is the one worth reading twice. The loop has no exclusion +settings, so none of the over-fetching machinery is doing anything — the entire +gain is [the plugin no longer leaking `query_id` into the cache key](#3-nothing-the-plugin-tracks-reaches-the-cache-key). +Because `query_id` is derived from the post ID, *every* query loop the plugin +touched previously got a private cache entry on every URL it rendered on. Three +distinct queries were being re-executed 201 times purely because their keys +differed. + +**Cost, where there is no cache benefit to be had** — one cold render of the +12-loop page against an empty cache, which is the worst case for the +over-fetching: + +| | before | after | +|---|---|---| +| Database queries | 87 | 66 | +| Render time | 74.9 ms | 73.8 ms | + +The extra rows and the per-request PHP filtering do not show up above the noise +(20 runs each, interleaved), and the query count still falls because the post +templates share one query. + +**The admin editor is unaffected**, as expected — no editor JavaScript changed +(the built bundle is byte-identical), and `query_loop_block_query_vars` does not +fire there. Medians of 15 requests, two rounds: + +| | before | after | +|---|---|---| +| Block editor (`post.php`) | 218–235 ms | 215–221 ms | +| Posts list (`edit.php`) | 61–65 ms | 62–63 ms | +| Site editor | 117–125 ms | 121–125 ms | +| REST `wp/v2/posts` (loop preview) | 28–30 ms | 28 ms | + +Every gap there is smaller than the spread between the two rounds of the +*same* build, so none of it is a real difference. + ## Not done yet Ranked by what they would be worth. diff --git a/inc/deferred-exclusions.php b/inc/deferred-exclusions.php index 2cdd227..0407652 100644 --- a/inc/deferred-exclusions.php +++ b/inc/deferred-exclusions.php @@ -173,9 +173,11 @@ function plan_query( $query, $block, $page = 1 ) { $exclude = $context['exclude'] ?? []; unset( $context['exclude'] ); - // Core applies the block's own "exclude current post" setting by adding the - // post ID to `post__not_in`, which is the single biggest source of per-URL - // cache keys. Take it over. + // The block's own "exclude current post" setting. Core (after 6.9) applies + // it by adding the post ID to `post__not_in`, which is a per-URL cache key + // by construction; taking it over here keeps the query stable. On 6.9 and + // earlier core ignores the attribute entirely, so this is what makes the + // setting work at all. if ( $block instanceof WP_Block && ! empty( $block->context['query']['excludeCurrent'] ) ) { $current_post_id = get_the_ID(); if ( $current_post_id ) { From bc9b486ade0ac6a01c85e69302f33a5dc96f57db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 09:21:25 +0000 Subject: [PATCH 03/12] Reproduce the benchmarks on WordPress 7.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same rig, second install on 7.1. The results hold: a plain query loop with no plugin settings goes from 306 database queries to 108 across 100 URLs, and the 12-loop page from 1261 to 70 across 40 URLs, with render time down roughly a third in both. The cold-render worst case stays within noise. Admin is unaffected on both versions. 7.1 also settles the `excludeCurrent` question. Core gained the attribute in 7.1 — not 6.9, and not 7.0, both of which ignore it — so on 7.1 this branch is output-identical on every fixture including the related-posts one, where on 6.9 it differs on the URLs whose current post falls inside the window. The version boundary in the docs was wrong in the other direction too and is now stated as measured rather than assumed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- CLAUDE.md | 2 +- README.md | 2 +- docs/query-caching.md | 106 ++++++++++++++++++++---------------- inc/deferred-exclusions.php | 6 +- 4 files changed, 65 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 285ec33..5ad3a10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ The plugin handles two different query scenarios: `post__not_in` puts excluded IDs into the SQL, and `WP_Query` derives its `post-queries` cache key from the SQL, so a loop excluding the post being viewed gets a private cache entry on every URL. For non-inherited queries the plugin instead over-fetches by `count( $exclude )` and drops the posts in PHP on `the_posts` — which core runs *after* writing the result to the object cache, so the shareable superset is what gets cached. - `plan_query()` (`query_loop_block_query_vars`, priority 999 — after presets) turns recorded exclusions and post-template windows into a fetch plan -- Sources: the `query.excludeCurrent` block attribute (implemented by core only after 6.9 — on 6.9 and earlier this plugin is what makes it do anything), the plugin's `excludeDisplayed`, and the `hm_query_loop_deferred_exclusions` filter +- Sources: the `query.excludeCurrent` block attribute (implemented by core only from 7.1 — on 7.0 and earlier this plugin is what makes it do anything), the plugin's `excludeDisplayed`, and the `hm_query_loop_deferred_exclusions` filter - `bind_context()` (`pre_get_posts`, priority 0) strips the plugin's state from the query vars before the cache key is generated, binding it to the `WP_Query` instance instead. **Any** custom query var reaches the cache key, so nothing this plugin tracks may be left in there - `filter_posts()` (`the_posts`, priority 9) applies the plan and corrects `found_posts`/`max_num_pages`; it runs before post tracking at priority 10 - Falls back to SQL exclusion when the fetch would exceed `hm_query_loop_max_deferred_fetch` (default 100), when `hm_query_loop_defer_exclusions` is false, or when the query cannot reach `the_posts` (`fields => ids`, `suppress_filters`) diff --git a/README.md b/README.md index f8b8bf6..be173c4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Register custom query configurations in PHP that can be selected from a dropdown Excluding posts with `post__not_in` gives every URL its own `WP_Query` cache entry, because the excluded IDs end up in the SQL the cache key is built from. For non-inherited query loops this plugin fetches a few extra posts instead and drops the unwanted ones in PHP, so "the latest 5 posts, excluding this one" is one cached query shared by every post on the site rather than one per post. -This applies to the plugin's own exclusion setting, to the `excludeCurrent` block attribute, and to anything added through the `hm_query_loop_deferred_exclusions` filter. Note that WordPress 6.9 and earlier ignore `excludeCurrent` entirely, so on those versions this plugin implements the setting rather than merely making it cacheable. See [Query caching](docs/query-caching.md) for the details and the trade-offs. +This applies to the plugin's own exclusion setting, to the `excludeCurrent` block attribute, and to anything added through the `hm_query_loop_deferred_exclusions` filter. Note that WordPress 7.0 and earlier ignore `excludeCurrent` entirely (core gained it in 7.1), so on those versions this plugin implements the setting rather than merely making it cacheable. See [Query caching](docs/query-caching.md) for the details and the trade-offs. ### 8. Sticky Posts diff --git a/docs/query-caching.md b/docs/query-caching.md index dc7a542..dda20a8 100644 --- a/docs/query-caching.md +++ b/docs/query-caching.md @@ -105,8 +105,8 @@ Three sources feed it: the WordPress version. Core trunk applies it in `build_query_vars_from_query_block()` by appending `get_the_ID()` to `post__not_in`; the plugin takes that ID back out and handles it in PHP, so no -configuration is needed to get the improvement. **WordPress 6.9 and earlier have -no `excludeCurrent` support at all** — core ignores the attribute. There the +configuration is needed to get the improvement. **WordPress 7.0 and earlier have +no `excludeCurrent` support at all** — it landed in 7.1 — core ignores the attribute. There the plugin does not take anything over, it implements the setting: a loop whose block attributes carry `excludeCurrent` starts excluding the current post where previously the attribute did nothing. That is the intended behaviour of the @@ -199,64 +199,78 @@ And for query presets, which would otherwise reach for `post__not_in`: ## Measured -Benchmarked against the commit this branch merges (`b9f3925`) on WordPress 6.9, -PHP 8.4, MariaDB on the same host, Twenty Twenty-Five, 601 posts across six -categories. Each "URL" is a full block render with the object cache persisting -between renders, which is what a persistent object cache does between requests -on a site whose content is not being edited. +Benchmarked against the commit this branch merges (`b9f3925`), on **WordPress +6.9 and 7.1**, PHP 8.4, MariaDB on the same host, Twenty Twenty-Five, 601 posts +across six categories. Each "URL" is a full block render with the object cache +persisting between renders, which is what a persistent object cache does between +requests on a site whose content is not being edited. -**A magazine-style page — 12 query loops, two of them split across multiple post -templates, most excluding what earlier loops showed — rendered on 40 URLs:** - -| | before | after | -|---|---|---| -| Database queries | 1263 | **72** | -| Query-loop `SELECT`s executed | 1201 | **23** | -| Render time | 50.4 ms/URL | **34.5 ms/URL** | -| Posts rendered | 2720 | 2720 (identical, post for post) | +**A single query loop with no plugin settings at all, across 100 URLs:** -**A single query loop with no plugin settings at all, rendered on 100 URLs:** - -| | before | after | -|---|---|---| -| Database queries | 306 | **108** | -| Query-loop `SELECT`s executed | 201 | **3** | -| Render time | 4.38 ms/URL | **3.34 ms/URL** | +| | WP 6.9 before | WP 6.9 after | WP 7.1 before | WP 7.1 after | +|---|---|---|---|---| +| Database queries | 308 | **110** | 306 | **108** | +| Query-loop `SELECT`s executed | 201 | **3** | 201 | **3** | +| Render time (ms/URL) | 3.94 | **2.92** | 4.61 | **3.38** | -That second table is the one worth reading twice. The loop has no exclusion -settings, so none of the over-fetching machinery is doing anything — the entire -gain is [the plugin no longer leaking `query_id` into the cache key](#3-nothing-the-plugin-tracks-reaches-the-cache-key). +That table is the one worth reading twice. The loop has no exclusion settings, so +none of the over-fetching machinery is doing anything — the entire gain is +[the plugin no longer leaking `query_id` into the cache key](#3-nothing-the-plugin-tracks-reaches-the-cache-key). Because `query_id` is derived from the post ID, *every* query loop the plugin touched previously got a private cache entry on every URL it rendered on. Three distinct queries were being re-executed 201 times purely because their keys differed. +**A magazine-style page — 12 query loops, two of them split across multiple post +templates, most excluding what earlier loops showed — across 40 URLs:** + +| | WP 6.9 before | WP 6.9 after | WP 7.1 before | WP 7.1 after | +|---|---|---|---|---| +| Database queries | 1263 | **72** | 1261 | **70** | +| Query-loop `SELECT`s executed | 1201 | **23** | 1201 | **23** | +| Render time (ms/URL) | 55.3 | **36.4** | 56.1 | **38.3** | +| Posts rendered | 2720 | 2720 | 2720 | 2720 | + **Cost, where there is no cache benefit to be had** — one cold render of the -12-loop page against an empty cache, which is the worst case for the -over-fetching: +12-loop page against an empty cache, the worst case for the over-fetching, 15–20 +interleaved runs per build: -| | before | after | -|---|---|---| -| Database queries | 87 | 66 | -| Render time | 74.9 ms | 73.8 ms | +| | WP 6.9 before | WP 6.9 after | WP 7.1 before | WP 7.1 after | +|---|---|---|---|---| +| Database queries | 87 | 66 | 87 | 66 | +| Render time | 74.9 ms | 73.8 ms | 77.9 ms | 76.6 ms | -The extra rows and the per-request PHP filtering do not show up above the noise -(20 runs each, interleaved), and the query count still falls because the post -templates share one query. +The extra rows and the per-request PHP filtering do not show up above the noise, +and the query count still falls because the post templates share one query. -**The admin editor is unaffected**, as expected — no editor JavaScript changed -(the built bundle is byte-identical), and `query_loop_block_query_vars` does not -fire there. Medians of 15 requests, two rounds: +**Rendered output**, compared post by post across 25 URLs per fixture: -| | before | after | +| Fixture | WP 6.9 | WP 7.1 | |---|---|---| -| Block editor (`post.php`) | 218–235 ms | 215–221 ms | -| Posts list (`edit.php`) | 61–65 ms | 62–63 ms | -| Site editor | 117–125 ms | 121–125 ms | -| REST `wp/v2/posts` (loop preview) | 28–30 ms | 28 ms | - -Every gap there is smaller than the spread between the two rounds of the -*same* build, so none of it is a real difference. +| 12-loop page | identical | identical | +| Plain loop | identical | identical | +| Loop with `excludeCurrent` | **differs on 5 of 25** | identical | + +The one divergence is the `excludeCurrent` version difference described above: on +6.9 (and any core up to 7.0) core ignores the attribute, so `before` leaves the current post in its own +"more like this" list and `after` removes it. The URLs that differ are exactly +those where the current post fell inside the window. On 7.1, where core +implements the attribute, the change is output-identical. + +**The admin editor is unaffected** on both versions, as expected — no editor +JavaScript changed (the built bundle is byte-identical), and +`query_loop_block_query_vars` does not fire there. Medians of 15 requests, two +rounds each, reported as a range across rounds: + +| | WP 6.9 before | WP 6.9 after | WP 7.1 before | WP 7.1 after | +|---|---|---|---|---| +| Block editor (`post.php`) | 218–235 ms | 215–221 ms | 257–265 ms | 251–252 ms | +| Posts list (`edit.php`) | 61–65 ms | 62–63 ms | 72–74 ms | 71–76 ms | +| Site editor | 117–125 ms | 121–125 ms | 138–139 ms | 131–145 ms | +| REST `wp/v2/posts` (loop preview) | 28–30 ms | 28 ms | 41–43 ms | 38–40 ms | + +Every gap there is smaller than the spread between two rounds of the *same* +build, so none of it is a real difference. ## Not done yet diff --git a/inc/deferred-exclusions.php b/inc/deferred-exclusions.php index 0407652..915ba20 100644 --- a/inc/deferred-exclusions.php +++ b/inc/deferred-exclusions.php @@ -173,9 +173,9 @@ function plan_query( $query, $block, $page = 1 ) { $exclude = $context['exclude'] ?? []; unset( $context['exclude'] ); - // The block's own "exclude current post" setting. Core (after 6.9) applies - // it by adding the post ID to `post__not_in`, which is a per-URL cache key - // by construction; taking it over here keeps the query stable. On 6.9 and + // The block's own "exclude current post" setting. Core from 7.1 applies it + // by adding the post ID to `post__not_in`, which is a per-URL cache key by + // construction; taking it over here keeps the query stable. On 7.0 and // earlier core ignores the attribute entirely, so this is what makes the // setting work at all. if ( $block instanceof WP_Block && ! empty( $block->context['query']['excludeCurrent'] ) ) { From 4106d485b5d5125c2b48bd151b1bf73d80bf10e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 09:38:18 +0000 Subject: [PATCH 04/12] Tidy the excludeCurrent version note An earlier edit spliced the corrected version boundary into the middle of a sentence and left it reading badly. Same facts, stated once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- docs/query-caching.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/query-caching.md b/docs/query-caching.md index dda20a8..4104ffc 100644 --- a/docs/query-caching.md +++ b/docs/query-caching.md @@ -97,20 +97,24 @@ Three sources feed it: | Source | Varies by | |---|---| -| Core's own `query.excludeCurrent` block attribute | URL — the worst case | +| The `query.excludeCurrent` block attribute | URL — the worst case | | This plugin's **Exclude already displayed posts** setting | Position on the page | | The `hm_query_loop_deferred_exclusions` filter | Whatever you want | `excludeCurrent` needs a word of explanation, because what it does depends on -the WordPress version. Core trunk applies it in -`build_query_vars_from_query_block()` by appending `get_the_ID()` to -`post__not_in`; the plugin takes that ID back out and handles it in PHP, so no -configuration is needed to get the improvement. **WordPress 7.0 and earlier have -no `excludeCurrent` support at all** — it landed in 7.1 — core ignores the attribute. There the -plugin does not take anything over, it implements the setting: a loop whose -block attributes carry `excludeCurrent` starts excluding the current post where -previously the attribute did nothing. That is the intended behaviour of the -setting, but it is a behaviour change on those versions, not just a caching one. +the WordPress version. **Core gained the attribute in 7.1**; 7.0 and earlier +ignore it entirely. + +From 7.1, core applies it in `build_query_vars_from_query_block()` by appending +`get_the_ID()` to `post__not_in`. The plugin takes that ID back out and handles +it in PHP, so no configuration is needed to get the improvement, and the +rendered output is unchanged. + +On 7.0 and earlier the plugin is not taking anything over — it is implementing +the setting. A loop whose block attributes carry `excludeCurrent` starts +excluding the current post where the attribute previously did nothing. That is +what the setting is supposed to do, but on those versions it is a behaviour +change and not only a caching one. ### 2. Post templates share one query From 019583fd4279c6428cad5441abb51e73b9bb13e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:52:28 +0000 Subject: [PATCH 05/12] Tests: follow the site editor's 7.x routes, and gate on 7.0 and 7.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress 7.0 moved the site editor onto path routes. The template editor used to be reached at `?postType=wp_template&postId=theme//slug`; it is now `?p=/wp_template/theme//slug`. The old URL does not error on 7.x — it quietly resolves to the dashboard route, so no editor header is drawn and every test that opened the settings sidebar timed out on a button that had never existed. That is all eight WP 7.0 failures and nine of the eleven on 7.1; the specs that drive the editor through `wp.data` rather than the chrome were unaffected, which is what pointed at navigation rather than at the panels. `visitSiteEditor` now tries the path route first and falls back to the query-string form, deciding which one worked by asking the editor store which entity it has open rather than by matching markup. The answer cannot change within a run, so it is remembered per worker. `openSettingsSidebar` no longer hunts for a header button at all. Where that button lives, and whether it is drawn, has moved between versions; `core/interface` and the two sidebar ids have not. Asking for the block sidebar directly also settles which tab opens, which the previous Block-tab click was working around. If it ever fails, it now reports the buttons that were on the page. The remaining two 7.1 failures were the grid Columns spinbutton, which 7.1 moves into a ToolsPanel. Those steps set up a layout the assertions never look at — the tests count posts on the front end — so they are gone rather than pinned to a version. With the suite passing, 7.0 and 7.1 move into the blocking matrix. Only trunk stays non-blocking, where a failure is news about WordPress rather than about the commit. Also restores the PHP unit tests job, which the merge of main dropped when it rewrote this workflow, and folds it into the `test` aggregate so the required check covers it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- .github/workflows/e2e-latest.yml | 5 +- .github/workflows/playwright-tests.yml | 44 ++-- tests/e2e/fixtures.js | 251 +++++++++++++++------- tests/e2e/multiple-post-templates.spec.js | 22 -- 4 files changed, 211 insertions(+), 111 deletions(-) diff --git a/.github/workflows/e2e-latest.yml b/.github/workflows/e2e-latest.yml index d726117..01fb08f 100644 --- a/.github/workflows/e2e-latest.yml +++ b/.github/workflows/e2e-latest.yml @@ -32,9 +32,8 @@ jobs: pull-requests: write uses: ./.github/workflows/playwright-tests.yml with: - # Pinned to the lane that is green, so a failure here means an AQL - # regression and nothing else. Pointing this at 7.0/7.1 would fail - # every week on the known WordPress 7.x gaps and bury the signal. + # One core version is enough: this varies the plugin, and running + # every lane would only multiply the same AQL signal. core_matrix: '[{ "label": "6.9", "core": "WordPress/WordPress#6.9.7", "comment": false }]' # Core trunk is already covered by the nightly lane on every push. experimental_matrix: '[]' diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 124f982..c49cae6 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -29,6 +29,23 @@ on: default: false jobs: + # The exclusion planner stubs WordPress out entirely, so it needs neither + # wp-env nor a browser and is worth running on its own. + php: + name: PHP unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - name: Run tests + run: npm run test:php + # Lanes live here rather than inline so a caller can narrow them without # duplicating the jobs below. lanes: @@ -50,19 +67,17 @@ jobs: # re-run of an old commit resolves to the same WordPress. read -r -d '' DEFAULT_BLOCKING <<'JSON' || true [ - { "label": "6.9", "core": "WordPress/WordPress#6.9.7", "comment": true } + { "label": "6.9", "core": "WordPress/WordPress#6.9.7", "comment": true }, + { "label": "7.0", "core": "WordPress/WordPress#7.0.4", "comment": true }, + { "label": "7.1", "core": "WordPress/WordPress#7.1", "comment": true } ] JSON - # 7.0 and 7.1 run on every PR but do not gate merging: the - # suite has real compatibility gaps on WordPress 7.x that - # predate this matrix (see the WP 7.x tracking issue). They - # move back to DEFAULT_BLOCKING once those are closed. - # nightly tracks trunk for early warning and stays here. + # Only trunk is non-blocking. A failure there is early warning + # of an upstream change, not a defect in the commit under test, + # so it must not be able to hold up a merge. read -r -d '' DEFAULT_EXPERIMENTAL <<'JSON' || true [ - { "label": "7.0", "core": "WordPress/WordPress#7.0.4", "comment": true }, - { "label": "7.1", "core": "WordPress/WordPress#7.1", "comment": true }, { "label": "nightly", "core": "WordPress/WordPress#master", "comment": false } ] JSON @@ -148,8 +163,7 @@ jobs: echo "### :warning: WP ${{ matrix.label }} e2e failed (non-blocking)" echo echo "This lane runs \`${{ matrix.core }}\` and does not gate merging." - echo "For trunk, a failure is early warning of an upstream change." - echo "For a released version, it is a known compatibility gap — see the WP 7.x tracking issue." + echo "It is early warning of an upstream change, not a defect in this commit." } >> "$GITHUB_STEP_SUMMARY" # Aggregate gate. Named `test` so the pre-existing required status check @@ -157,14 +171,16 @@ jobs: # branch protection does not know about. test: name: test - needs: e2e + needs: [ e2e, php ] if: always() runs-on: ubuntu-latest steps: - name: Check lane results env: - RESULT: ${{ needs.e2e.result }} + E2E: ${{ needs.e2e.result }} + PHP: ${{ needs.php.result }} run: | set -euo pipefail - echo "Blocking lanes: $RESULT" - [ "$RESULT" = "success" ] + echo "Blocking e2e lanes: $E2E" + echo "PHP unit tests: $PHP" + [ "$E2E" = "success" ] && [ "$PHP" = "success" ] diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index bf1361d..191da00 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -6,6 +6,41 @@ const { const { Locator } = require( '@playwright/test' ); const { execSync } = require( 'child_process' ); +/** + * Build the site editor URLs that can open a template, newest scheme first. + * + * WordPress 7.0 moved the site editor onto path routes: the template editor + * used to be `?postType=wp_template&postId=theme//slug`, and is now + * `?p=/wp_template/theme//slug`. The old form does not error on 7.x — it + * quietly resolves to the dashboard route, which renders no editor header, so + * every test that reached for the settings sidebar timed out looking for a + * button that had never been drawn. + * + * @param {string} templateId Template id, `theme//slug`. + * @return {string[]} Admin paths to try in order. + */ +function siteEditorRoutes( templateId ) { + return [ + `site-editor.php?p=${ encodeURIComponent( + `/wp_template/${ templateId }` + ) }&canvas=edit`, + `site-editor.php?postId=${ encodeURIComponent( + templateId + ) }&postType=wp_template&canvas=edit`, + ]; +} + +/** + * The route scheme this WordPress accepts, remembered after the first visit. + * + * Probing costs a timeout on whichever scheme is wrong, and the answer cannot + * change within a run. The module is loaded once per worker, so this is a + * per-worker cache. + * + * @type {number|null} + */ +let resolvedSiteEditorRoute = null; + /** * Extended test fixtures with additional utilities. */ @@ -19,6 +54,74 @@ export const test = base.extend( { * @param use */ blockEditor: async ( { admin, editor, page }, use ) => { + /** + * Dismiss the modals the site editor can open on a cold profile. + */ + async function dismissSiteEditorModals() { + // Dismiss "Edit your site" modal if it appears + const editSiteModalVisible = await page + .locator( 'text=Edit your site' ) + .isVisible( { timeout: 2000 } ) + .catch( () => false ); + + if ( editSiteModalVisible ) { + const getStartedButton = page.locator( + 'button:has-text("Get started")' + ); + const isGetStartedVisible = await getStartedButton + .isVisible( { timeout: 1000 } ) + .catch( () => false ); + if ( isGetStartedVisible ) { + await getStartedButton.click(); + await page.waitForTimeout( 500 ); + } + } + + // Close welcome guide if it appears + const welcomeGuideVisible = await page + .locator( '.edit-site-welcome-guide, .edit-post-welcome-guide' ) + .isVisible( { timeout: 2000 } ) + .catch( () => false ); + + if ( welcomeGuideVisible ) { + const closeButton = page.locator( + 'button[aria-label="Close"]' + ); + const isCloseButtonVisible = await closeButton + .isVisible( { timeout: 1000 } ) + .catch( () => false ); + if ( isCloseButtonVisible ) { + await closeButton.click(); + await page.waitForTimeout( 500 ); + } + } + } + + /** + * Whether the editor has the given template open for editing. + * + * Asking the editor store which entity it is on distinguishes the edit + * canvas from every other thing the site editor can render, and does it + * without depending on any markup. + * + * @param {string} templateId Template id, `theme//slug`. + * @param {number} timeout How long to allow the editor to boot. + * @return {Promise} True when the template is open. + */ + async function isEditingTemplate( templateId, timeout ) { + return page + .waitForFunction( + ( id ) => + window.wp?.data + ?.select( 'core/editor' ) + ?.getCurrentPostId() === id, + templateId, + { timeout } + ) + .then( () => true ) + .catch( () => false ); + } + const blockEditorUtils = { /** * Navigate to the site editor to edit a template. @@ -30,102 +133,106 @@ export const test = base.extend( { theme = 'twentytwentyfive' ) { const templateId = `${ theme }//${ templateSlug }`; - await admin.visitAdminPage( - `site-editor.php?postId=${ encodeURIComponent( - templateId - ) }&postType=wp_template&canvas=edit` - ); - - // Wait for site editor to load - await page.waitForSelector( - '.edit-site-layout, iframe[name="editor-canvas"]', - { timeout: 15000 } - ); + const allRoutes = siteEditorRoutes( templateId ); + const routes = + resolvedSiteEditorRoute === null + ? allRoutes + : [ allRoutes[ resolvedSiteEditorRoute ] ]; - // Dismiss "Edit your site" modal if it appears - const editSiteModalVisible = await page - .locator( 'text=Edit your site' ) - .isVisible( { timeout: 2000 } ) - .catch( () => false ); + for ( const route of routes ) { + await admin.visitAdminPage( route ); - if ( editSiteModalVisible ) { - const getStartedButton = page.locator( - 'button:has-text("Get started")' + // Wait for site editor to load + await page.waitForSelector( + '.edit-site-layout, iframe[name="editor-canvas"]', + { timeout: 15000 } ); - const isGetStartedVisible = await getStartedButton - .isVisible( { timeout: 1000 } ) - .catch( () => false ); - if ( isGetStartedVisible ) { - await getStartedButton.click(); - await page.waitForTimeout( 500 ); - } - } - // Close welcome guide if it appears - const welcomeGuideVisible = await page - .locator( - '.edit-site-welcome-guide, .edit-post-welcome-guide' - ) - .isVisible( { timeout: 2000 } ) - .catch( () => false ); + await dismissSiteEditorModals(); - if ( welcomeGuideVisible ) { - const closeButton = page.locator( - 'button[aria-label="Close"]' - ); - const isCloseButtonVisible = await closeButton - .isVisible( { timeout: 1000 } ) - .catch( () => false ); - if ( isCloseButtonVisible ) { - await closeButton.click(); - await page.waitForTimeout( 500 ); + if ( await isEditingTemplate( templateId, 15000 ) ) { + resolvedSiteEditorRoute = allRoutes.indexOf( route ); + + // Give the editor time to initialize + await page.waitForTimeout( 1000 ); + return; } } - // Give the editor time to initialize - await page.waitForTimeout( 1000 ); + throw new Error( + `The site editor did not open ${ templateId } for editing. Tried: ${ routes.join( + ', ' + ) }` + ); }, /** * Open the settings sidebar and wait for it to be ready. * - * editor.openDocumentSettingsSidebar() insists on a header button - * named exactly "Settings" inside the "Editor top bar" region. That - * button is not reachable in the site editor on WordPress 7.x, so - * every test that opened the sidebar there timed out while the same - * tests passed in the post editor. Check whether the sidebar is - * already open first, and fall back to any Settings toggle if the - * core helper cannot find its own. + * Done through the interface store rather than by clicking a header + * button. editor.openDocumentSettingsSidebar() wants a button named + * exactly "Settings" inside the "Editor top bar" region, and where + * that button lives — or whether it is drawn at all — has moved + * between WordPress versions. The `core/interface` store and the two + * sidebar ids have not moved since 6.6. + * + * Asking for the block sidebar directly also settles which tab + * opens: on the Document tab none of the panels this plugin adds to + * core/query are rendered, so they all look missing. */ async openSettingsSidebar() { const settingsRegion = page.getByRole( 'region', { name: 'Editor settings', } ); - if ( - await settingsRegion - .isVisible( { timeout: 2000 } ) - .catch( () => false ) - ) { - await page.waitForTimeout( 1000 ); - return; - } + await page + .evaluate( () => { + const { select, dispatch } = window.wp.data; + const hasSelection = + !! select( + 'core/block-editor' + ).getBlockSelectionStart(); + + dispatch( 'core/interface' ).enableComplementaryArea( + 'core', + hasSelection + ? 'edit-post/block' + : 'edit-post/document' + ); + } ) + // Leave a missing store to the fallback below rather than + // failing here, so the diagnostic still gets a chance to run. + .catch( () => {} ); try { - await editor.openDocumentSettingsSidebar(); + await settingsRegion.waitFor( { timeout: 10000 } ); } catch ( error ) { - await page - .getByRole( 'button', { name: 'Settings' } ) - .first() - .click(); - await settingsRegion.waitFor( { timeout: 15000 } ); + // Fall back to the core helper, then report what was on the + // page — a failure here is only ever read in CI output. + try { + await editor.openDocumentSettingsSidebar(); + await settingsRegion.waitFor( { timeout: 10000 } ); + } catch ( fallbackError ) { + const buttons = await page + .getByRole( 'button' ) + .evaluateAll( ( nodes ) => + nodes + .map( + ( node ) => + node.getAttribute( 'aria-label' ) || + node.textContent.trim() + ) + .filter( Boolean ) + ); + + throw new Error( + `Could not open the settings sidebar. Buttons on the page: ${ buttons.join( + ' | ' + ) }` + ); + } } - // The sidebar can open on the Template/Document tab, which holds - // no block inspector controls — so every panel this plugin adds - // to core/query looks missing. The same panels render fine in the - // post editor on the same WordPress, which is what points at the - // tab rather than at the panels themselves. const blockTab = page.getByRole( 'tab', { name: 'Block' } ); if ( await blockTab diff --git a/tests/e2e/multiple-post-templates.spec.js b/tests/e2e/multiple-post-templates.spec.js index 2e942f0..3e61fc1 100644 --- a/tests/e2e/multiple-post-templates.spec.js +++ b/tests/e2e/multiple-post-templates.spec.js @@ -121,7 +121,6 @@ test.describe( 'Multiple Post Templates', () => { .getByLabel( 'Options' ) .click(); await page.getByRole( 'menuitem', { name: /^Duplicate / } ).click(); - await page.getByRole( 'button', { name: 'Grid view' } ).click(); await page .getByRole( 'button', { name: 'Post Template Settings' } ) .click(); @@ -134,21 +133,11 @@ test.describe( 'Multiple Post Templates', () => { await page .getByRole( 'spinbutton', { name: 'Posts per template' } ) .fill( '2' ); - await page.getByRole( 'spinbutton', { name: 'Columns' } ).click(); - await page - .getByRole( 'spinbutton', { name: 'Columns' } ) - .press( 'Shift+ArrowLeft' ); - await page.getByRole( 'spinbutton', { name: 'Columns' } ).fill( '2' ); await page .getByRole( 'toolbar', { name: 'Block tools' } ) .getByLabel( 'Options' ) .click(); await page.getByRole( 'menuitem', { name: /^Duplicate / } ).click(); - await page.getByRole( 'spinbutton', { name: 'Columns' } ).click(); - await page - .getByRole( 'spinbutton', { name: 'Columns' } ) - .press( 'Shift+ArrowLeft' ); - await page.getByRole( 'spinbutton', { name: 'Columns' } ).fill( '3' ); await page .getByRole( 'button', { name: 'Post Template Settings' } ) .click(); @@ -244,7 +233,6 @@ test.describe( 'Multiple Post Templates', () => { await page.getByRole( 'menuitem', { name: /^Duplicate / } ).click(); // Configure second post template: 2 posts - await page.getByRole( 'button', { name: 'Grid view' } ).click(); await page .getByRole( 'button', { name: 'Post Template Settings' } ) .click(); @@ -257,11 +245,6 @@ test.describe( 'Multiple Post Templates', () => { await page .getByRole( 'spinbutton', { name: 'Posts per template' } ) .fill( '2' ); - await page.getByRole( 'spinbutton', { name: 'Columns' } ).click(); - await page - .getByRole( 'spinbutton', { name: 'Columns' } ) - .press( 'Shift+ArrowLeft' ); - await page.getByRole( 'spinbutton', { name: 'Columns' } ).fill( '2' ); // Duplicate to create third post template await page @@ -271,11 +254,6 @@ test.describe( 'Multiple Post Templates', () => { await page.getByRole( 'menuitem', { name: /^Duplicate / } ).click(); // Configure third post template: leave Posts per template EMPTY (should auto-calculate to 7) - await page.getByRole( 'spinbutton', { name: 'Columns' } ).click(); - await page - .getByRole( 'spinbutton', { name: 'Columns' } ) - .press( 'Shift+ArrowLeft' ); - await page.getByRole( 'spinbutton', { name: 'Columns' } ).fill( '3' ); await page .getByRole( 'button', { name: 'Post Template Settings' } ) .click(); From 8b90521765db7e65f70716b7c5ca0c8e515f13f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:05:19 +0000 Subject: [PATCH 06/12] Tests: dismiss the site editor's welcome modal on WordPress 7.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route fix got the editor open; the modal over it was the next thing in the way. Every remaining 7.0 and 7.1 failure reported the same page state — "Buttons on the page: Get started" — which is the welcome modal and nothing else. Two reasons it was missed. The dismissal was gated on the modal's surrounding copy ("Edit your site"), which is not what 7.x shows, and it ran before the editor had booted, when there was nothing to find yet. It now runs after the editor confirms the template is open, and drives off the buttons rather than the copy. Underneath both: `locator.isVisible()` ignores the timeout it is handed and answers immediately, so these probes were racing whatever the editor rendered a beat later. Replaced with a real wait here and in expandPanel, where a panel left collapsed keeps its controls out of the DOM and the failure lands later on whichever control the test wanted — which is how "should apply query preset on frontend" failed on 7.1, in the post editor, nowhere near the site editor routes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- tests/e2e/fixtures.js | 133 +++++++++++++++++++++++------------------- 1 file changed, 72 insertions(+), 61 deletions(-) diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 191da00..e44a166 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -55,44 +55,56 @@ export const test = base.extend( { */ blockEditor: async ( { admin, editor, page }, use ) => { /** - * Dismiss the modals the site editor can open on a cold profile. + * Click a control if it turns up within the grace period. + * + * `locator.isVisible()` answers immediately — the timeout it accepts is + * ignored — so probing with it races anything the editor renders a beat + * after the page settles. Waiting is the difference between dismissing a + * modal and running the rest of the test underneath it. + * + * @param {Locator} locator Control to click. + * @param {number} timeout How long to wait for it. + * @return {Promise} Whether it was there and got clicked. */ - async function dismissSiteEditorModals() { - // Dismiss "Edit your site" modal if it appears - const editSiteModalVisible = await page - .locator( 'text=Edit your site' ) - .isVisible( { timeout: 2000 } ) - .catch( () => false ); - - if ( editSiteModalVisible ) { - const getStartedButton = page.locator( - 'button:has-text("Get started")' - ); - const isGetStartedVisible = await getStartedButton - .isVisible( { timeout: 1000 } ) - .catch( () => false ); - if ( isGetStartedVisible ) { - await getStartedButton.click(); - await page.waitForTimeout( 500 ); - } + async function clickIfPresent( locator, timeout = 3000 ) { + try { + await locator.first().waitFor( { state: 'visible', timeout } ); + } catch ( error ) { + return false; } - // Close welcome guide if it appears - const welcomeGuideVisible = await page - .locator( '.edit-site-welcome-guide, .edit-post-welcome-guide' ) - .isVisible( { timeout: 2000 } ) - .catch( () => false ); + await locator.first().click(); + await page.waitForTimeout( 500 ); + return true; + } - if ( welcomeGuideVisible ) { - const closeButton = page.locator( - 'button[aria-label="Close"]' - ); - const isCloseButtonVisible = await closeButton - .isVisible( { timeout: 1000 } ) - .catch( () => false ); - if ( isCloseButtonVisible ) { - await closeButton.click(); - await page.waitForTimeout( 500 ); + /** + * Dismiss the modals the site editor can open on a cold profile. + * + * Driven off the buttons rather than off the surrounding copy: the + * "Edit your site" wording this used to look for is not what WordPress + * 7.x shows, so the modal went unnoticed and every later step ran + * against a page whose only button was "Get started". + * + * Dismissing one can reveal another, so keep going until nothing more + * appears. + */ + async function dismissSiteEditorModals() { + for ( let attempt = 0; attempt < 3; attempt++ ) { + const dismissed = + ( await clickIfPresent( + page.getByRole( 'button', { name: 'Get started' } ) + ) ) || + ( await clickIfPresent( + page + .locator( + '.edit-site-welcome-guide, .edit-post-welcome-guide' + ) + .getByRole( 'button', { name: 'Close' } ) + ) ); + + if ( ! dismissed ) { + return; } } } @@ -148,11 +160,14 @@ export const test = base.extend( { { timeout: 15000 } ); - await dismissSiteEditorModals(); - if ( await isEditingTemplate( templateId, 15000 ) ) { resolvedSiteEditorRoute = allRoutes.indexOf( route ); + // After the editor has booted, not before: the modals + // are part of what it renders, so looking for them any + // earlier finds nothing. + await dismissSiteEditorModals(); + // Give the editor time to initialize await page.waitForTimeout( 1000 ); return; @@ -247,18 +262,29 @@ export const test = base.extend( { }, /** - * Expand a settings panel if it's not already open. + * Expand a settings panel, waiting for it to be rendered first. + * + * The inspector is still filling in when the sidebar first reports + * itself open. A panel left collapsed keeps its controls out of the + * DOM, so skipping it here surfaces as a missing control later, in + * whichever test happened to ask for one. + * * @param {string} panelTitle - The title text of the panel to expand. */ async expandPanel( panelTitle ) { - const panel = page.locator( - `.components-panel__body-title:has-text("${ panelTitle }")` - ); - const isExpanded = await panel + const toggle = page + .locator( + `.components-panel__body-title:has-text("${ panelTitle }")` + ) .locator( 'button' ) - .getAttribute( 'aria-expanded' ); - if ( isExpanded !== 'true' ) { - await panel.locator( 'button' ).click(); + .first(); + + await toggle.waitFor( { state: 'visible', timeout: 15000 } ); + + if ( + ( await toggle.getAttribute( 'aria-expanded' ) ) !== 'true' + ) { + await toggle.click(); await page.waitForTimeout( 300 ); } }, @@ -350,24 +376,9 @@ export const test = base.extend( { } }, async openSettingsPanel() { - const extraSettingsPanel = page.locator( - '.components-panel__body-title:has-text("Extra Query Loop Settings")' + await blockEditorUtils.expandPanel( + 'Extra Query Loop Settings' ); - if ( - await extraSettingsPanel - .isVisible( { timeout: 2000 } ) - .catch( () => false ) - ) { - const isExpanded = await extraSettingsPanel - .locator( 'button' ) - .getAttribute( 'aria-expanded' ); - if ( isExpanded !== 'true' ) { - await extraSettingsPanel - .locator( 'button' ) - .click(); - await page.waitForTimeout( 300 ); - } - } }, async excludeDisplayed() { const excludeDisplayedToggle = page From e898f4d38748c78297c287a3625f924c6defcf31 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:16:36 +0000 Subject: [PATCH 07/12] Tests: wait for a block to exist before selecting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the modal out of the way the sidebar opens, and the panel list it reported named the next problem: `[ 'Layout', 'Post Template Settings', 'Advanced' ]` on the first attempt and `[ 'Content' ]` on the retries. The first is the post template's inspector, the second is the Document tab — neither is the Query Loop block, so of course none of this plugin's panels were there. selectBlock.byName dispatched once into a store that had not parsed the template's blocks yet, found an empty list, and silently did nothing. In the post editor the content is already in the store when the helper runs, which is why this only ever showed up in the site editor. It now waits for the block to exist and then for the selection to take. expandPanel reports the panels that were present when it cannot find the one it wants, on the same reasoning as the sidebar diagnostic: this suite can only be watched through CI, so a failure has to explain itself in one round. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- tests/e2e/fixtures.js | 62 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index e44a166..dfc862a 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -279,7 +279,22 @@ export const test = base.extend( { .locator( 'button' ) .first(); - await toggle.waitFor( { state: 'visible', timeout: 15000 } ); + try { + await toggle.waitFor( { + state: 'visible', + timeout: 15000, + } ); + } catch ( error ) { + const panels = await page + .locator( '.components-panel__body-title' ) + .allTextContents(); + + throw new Error( + `No "${ panelTitle }" panel in the inspector. Panels present: ${ + panels.join( ' | ' ) || '(none)' + }` + ); + } if ( ( await toggle.getAttribute( 'aria-expanded' ) ) !== 'true' @@ -329,23 +344,46 @@ export const test = base.extend( { selectBlock: { /** * Select a block by its name. + * + * Waits for the block to exist and then for the selection to take. + * Dispatching once was a coin toss in the site editor, where the + * template's blocks are parsed a beat after the editor reports the + * entity open: the dispatch found an empty list and did nothing, and + * the test went on to read whichever inspector happened to be showing. + * * @param {string} blockName - The block name (e.g., 'core/post-template'). * @param {number} index - The index of the block to select (default: 0). */ async byName( blockName, index = 0 ) { - await page.evaluate( - ( { name, idx } ) => { - const blocks = window.wp.data + const target = { name: blockName, idx: index }; + + await page.waitForFunction( + ( { name, idx } ) => + window.wp.data .select( 'core/block-editor' ) - .getBlocksByName( name ); - if ( blocks.length > idx ) { - window.wp.data - .dispatch( 'core/block-editor' ) - .selectBlock( blocks[ idx ] ); - } - }, - { name: blockName, idx: index } + .getBlocksByName( name ).length > idx, + target, + { timeout: 15000 } ); + + await page.evaluate( ( { name, idx } ) => { + const blocks = window.wp.data + .select( 'core/block-editor' ) + .getBlocksByName( name ); + window.wp.data + .dispatch( 'core/block-editor' ) + .selectBlock( blocks[ idx ] ); + }, target ); + + await page.waitForFunction( ( { name, idx } ) => { + const store = + window.wp.data.select( 'core/block-editor' ); + return ( + store.getBlockSelectionStart() === + store.getBlocksByName( name )[ idx ] + ); + }, target ); + await page.waitForTimeout( 500 ); }, From 28ba017e4d2a99ece368fc2f9d4b712d1e0472ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:28:04 +0000 Subject: [PATCH 08/12] Tests: land the inspector on the Block tab, not Document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Panels present: Content` on every 7.0 failure — the Document tab, which holds none of the panels this plugin adds. Block selection is right now; the tab was not. Enabling the block sidebar through the interface store is meant to settle which tab opens, and does not always: the sidebar mounts after the dispatch and picks its own default. Clicking the tab is the part that sticks, and that click was still guarded by the `isVisible()` probe this suite has been using as though it waits — so on a tab list that had not rendered yet, it was skipped. Wait for the tab, click it, then wait for it to take. The "Block" label itself is unchanged across 6.9, 7.0 and 7.1, so nothing here is version-specific; the race just lost more often on 7.x. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- tests/e2e/fixtures.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index dfc862a..8a8076e 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -248,14 +248,22 @@ export const test = base.extend( { } } - const blockTab = page.getByRole( 'tab', { name: 'Block' } ); + // Then make sure the Block tab is the one showing. Asking the + // interface store for the block sidebar is meant to settle this + // on its own, but the tab can still come up on Document — the + // sidebar mounts after the dispatch and picks its own default. + // Clicking is the part that sticks, so wait for the tab rather + // than probing for it, and wait again for it to take. if ( - await blockTab - .isVisible( { timeout: 2000 } ) - .catch( () => false ) + await clickIfPresent( + page.getByRole( 'tab', { name: 'Block' } ), + 10000 + ) ) { - await blockTab.click(); - await page.waitForTimeout( 300 ); + await page + .getByRole( 'tab', { name: 'Block', selected: true } ) + .waitFor( { state: 'visible', timeout: 10000 } ) + .catch( () => {} ); } await page.waitForTimeout( 1000 ); From d364e270c04b43853ee6e345dd8121b9a06cde7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:39:18 +0000 Subject: [PATCH 09/12] Tests: report the editor's state when a panel is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rounds of "Panels present: Content" have said what is not there without saying why. The inspector diagnostic now carries the active complementary area, the selected block's name, and the tab list with which tab is selected — enough to tell a wrong tab from a wrong block from a panel that genuinely is not rendered on this WordPress. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- tests/e2e/fixtures.js | 62 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 8a8076e..29da5eb 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -109,6 +109,60 @@ export const test = base.extend( { } } + /** + * Describe what the inspector is actually showing. + * + * This suite can only be watched through CI, so when a control is not + * where a test expects it, the failure has to carry enough state to say + * why in one round rather than several. + * + * @return {Promise} One-line summary of the editor's state. + */ + async function describeInspector() { + const state = await page + .evaluate( () => { + const { select } = window.wp.data; + const blockEditorStore = select( 'core/block-editor' ); + const selected = blockEditorStore.getBlockSelectionStart(); + + return { + area: select( + 'core/interface' + ).getActiveComplementaryArea( 'core' ), + block: selected + ? blockEditorStore.getBlockName( selected ) + : null, + }; + } ) + .catch( () => ( { area: '?', block: '?' } ) ); + + const tabs = await page + .getByRole( 'tab' ) + .evaluateAll( ( nodes ) => + nodes.map( + ( node ) => + `${ node.textContent.trim() }${ + node.getAttribute( 'aria-selected' ) === 'true' + ? '*' + : '' + }` + ) + ) + .catch( () => [] ); + + const panels = await page + .locator( '.components-panel__body-title' ) + .allTextContents() + .catch( () => [] ); + + return [ + `area=${ state.area }`, + `block=${ state.block }`, + `tabs=[${ tabs.join( ', ' ) }]`, + `panels=[${ panels.join( ', ' ) }]`, + ].join( ' ' ); + } + /** * Whether the editor has the given template open for editing. * @@ -293,14 +347,8 @@ export const test = base.extend( { timeout: 15000, } ); } catch ( error ) { - const panels = await page - .locator( '.components-panel__body-title' ) - .allTextContents(); - throw new Error( - `No "${ panelTitle }" panel in the inspector. Panels present: ${ - panels.join( ' | ' ) || '(none)' - }` + `No "${ panelTitle }" panel in the inspector. ${ await describeInspector() }` ); } From a2c020a5b5bb6e0c0c106b636b5c6e432edb27ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:51:20 +0000 Subject: [PATCH 10/12] Tests: probe for the lazy placeholder in the inspector diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last round settled the scaffolding: area=edit-post/block, block=core/query, Block tab selected — the right block, the right tab, and still no panel. So the block's edit component is not mounted, and the two candidates are this plugin's own viewport placeholder standing in for it, or a canvas that is not in edit mode. The placeholder carries a class of its own, so the diagnostic can just say which. It now also prints the visible tab panel's text, since allTextContents() does not filter hidden nodes and "panels=[Content]" may have been the hidden Template panel all along — in which case the Block tab is rendering nothing at all, which is the answer rather than a clue. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab --- tests/e2e/fixtures.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 29da5eb..0ae17a6 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -155,11 +155,30 @@ export const test = base.extend( { .allTextContents() .catch( () => [] ); + // Whether the block is standing behind its own lazy placeholder. While + // that is showing, neither the core block nor any of this plugin's + // inspector controls are mounted, so an empty Block tab is expected + // rather than surprising. + const placeholders = await editor.canvas + .locator( '.hm-query-loop-viewport-placeholder' ) + .count() + .catch( () => -1 ); + + const inspector = await page + .locator( '[role="tabpanel"]:not([hidden])' ) + .first() + .innerText() + .catch( () => '' ); + return [ `area=${ state.area }`, `block=${ state.block }`, `tabs=[${ tabs.join( ', ' ) }]`, `panels=[${ panels.join( ', ' ) }]`, + `placeholders=${ placeholders }`, + `inspector=${ JSON.stringify( + inspector.replace( /\s+/g, ' ' ).slice( 0, 200 ) + ) }`, ].join( ' ' ); } From 4f64101f3ec440af6a805031d6754ac817081537 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Tue, 15 Sep 2026 16:28:35 +0100 Subject: [PATCH 11/12] Tests: enter pattern-editing mode before reading the Block inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress 7.0 and 7.1 content-lock template patterns by default — for example twentytwentyfive's "List of posts" pattern, which wraps the Query Loop block on the index template used throughout this suite. Selecting a block inside one still shows the pattern's own card and a single "Content" panel in the sidebar, none of this plugin's controls, until "Edit pattern" is clicked to enter editing mode for the blocks inside it. The a2c020a diagnostic probe (placeholders=0) had already ruled out the lazy viewport placeholder as the cause. Reproducing locally against WP 7.0.4 and dumping the sidebar's DOM showed the real block card: title "List of posts, 1 column" with a "Pattern" badge, plus an "Edit pattern" button — the content-locking UI, not a missing panel. openSettingsSidebar() now clicks that button (scoped to the "Editor settings" region, since an identical-looking button also lives in the block toolbar) once the Block tab is confirmed active, and waits for the inspector to re-render with the selected block's own controls. This runs unconditionally but is a no-op off pattern-locked content, so it costs nothing on 6.9 or on non-templated pages. Co-Authored-By: Claude Sonnet 5 --- tests/e2e/fixtures.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 0ae17a6..4f66ca2 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -339,6 +339,24 @@ export const test = base.extend( { .catch( () => {} ); } + // WordPress 7.0 and 7.1 content-lock template patterns (e.g. + // the "List of posts" pattern twentytwentyfive's index + // template uses for its Query Loop). Selecting a block + // inside one still shows the pattern's own card and a + // single "Content" panel — none of this plugin's controls — + // until "Edit pattern" is clicked to enter editing mode for + // the blocks inside it. + if ( + await clickIfPresent( + settingsRegion.getByRole( 'button', { + name: 'Edit pattern', + } ), + 2000 + ) + ) { + await page.waitForTimeout( 500 ); + } + await page.waitForTimeout( 1000 ); }, From 293a82aede9b71dd011d10d2225a00b429f523a6 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Tue, 15 Sep 2026 16:28:40 +0100 Subject: [PATCH 12/12] Tests: give the frontend preset test a complete query attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress 7.1's core/query edit component throws reading a post type label when query.postType is undefined, crashing the block's React tree with "Cannot read properties of undefined (reading 'singular_name')" — visible as zero inspector panels at all, not even core's own, rather than a missing plugin panel. query is a single object-shaped block attribute, so passing a partial object to editor.insertBlock() replaces block.json's whole default (which does set postType: 'post') instead of merging into it. Earlier WordPress releases papered over the resulting undefined postType with a defensive fallback in core's own edit.js; 7.1 no longer has one. Confirmed locally against WP 7.1 by reproducing the crash and fixing it with an explicit postType, which the block.json default was always supposed to provide. Co-Authored-By: Claude Sonnet 5 --- tests/e2e/query-presets.spec.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/e2e/query-presets.spec.js b/tests/e2e/query-presets.spec.js index ac86d89..ce44ad5 100644 --- a/tests/e2e/query-presets.spec.js +++ b/tests/e2e/query-presets.spec.js @@ -165,10 +165,17 @@ test.describe( 'Query Presets', () => { title: 'Preset Test Page', } ); - // Insert a Query Loop block with inner blocks to bypass the pattern chooser + // Insert a Query Loop block with inner blocks to bypass the pattern + // chooser. `query` is a single object-shaped attribute, so passing a + // partial value here replaces block.json's whole default rather than + // merging into it — postType must be given explicitly, or WordPress + // 7.1's core/query edit component crashes trying to read the label + // of an undefined post type. await editor.insertBlock( { name: 'core/query', - attributes: { query: { inherit: false, perPage: 5 } }, + attributes: { + query: { inherit: false, perPage: 5, postType: 'post' }, + }, innerBlocks: [ { name: 'core/post-template',