From 996399dfabf019105e0ffd0219b04f59c4d82e39 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Mon, 21 Sep 2026 13:23:12 +0100 Subject: [PATCH 1/3] Tests: return the last line of WP-CLI output, not the trailing blank --- tests/e2e/fixtures.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index bf1361d..7dee33c 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -304,7 +304,7 @@ export function wpCli( command ) { stdio: 'pipe', } ); - return String( result.split( '\n' ).slice( -1 ) ); + return String( result.trim().split( '\n' ).slice( -1 ) ); } catch ( error ) { console.error( `WP-CLI command failed: ${ command }` ); console.error( error.stdout || error.message ); From f0a237c8028a2d37eea64308b11b5d85c7806a49 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Mon, 21 Sep 2026 13:23:13 +0100 Subject: [PATCH 2/3] Add a setting to exclude the current post from a query loop Suggested posts on a single post template need to leave out the post being read. post__not_in scales poorly, so the loop's first page fetches one extra post and drops the current one in PHP. Later pages and pagination counts still use post__not_in so offsets stay exact. --- CLAUDE.md | 8 ++ README.md | 16 +++- hm-query-loop.php | 119 +++++++++++++++++++++++-- src/index.js | 30 ++++++- tests/e2e/exclude-current-post.spec.js | 111 +++++++++++++++++++++++ 5 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/exclude-current-post.spec.js diff --git a/CLAUDE.md b/CLAUDE.md index 58d5918..1bb4c57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ The plugin exposes an `hmQueryLoop` context object from `core/query` to `core/po perPage: number | undefined, // Custom posts per page value hideOnPaged: boolean, // Whether to hide on paginated pages excludeDisplayed: boolean, // Whether to exclude displayed posts + excludeCurrentPost: boolean, // Whether to exclude the post being viewed (non-inherited queries only) useElasticPress: boolean, // Whether to route query through ElasticPress (only shown when EP is active) } ``` @@ -61,6 +62,12 @@ The plugin handles two different query scenarios: - 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` +### Exclude Current Post +- `excludeCurrentPost` leaves out `get_queried_object_id()` on singular views, and does nothing elsewhere, including editor previews +- `post__not_in` scales poorly, so the post template's page 1 query fetches `posts_per_page + 1` instead. `remove_current_post_from_results` (`the_posts`, priority 9, before tracking) drops the current post and trims back to the loop's count +- Every other query from the loop (page 2+, and pagination blocks counting pages) uses `exclude_posts_from_query`, so offsets and page counts stay exact. Only paginated loops pay for `NOT IN` +- Loops with `post__in` drop the ID from that list instead + ### 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. @@ -114,6 +121,7 @@ The plugin provides a PHP API for registering custom query presets that can be s - `tests/e2e/multiple-post-templates.spec.js` - E2E tests for multiple post templates - `tests/e2e/unique-query-id.spec.js` - E2E tests for query ID deduplication - `tests/e2e/exclude-with-post-in.spec.js` - E2E tests for exclusion with post__in queries +- `tests/e2e/exclude-current-post.spec.js` - E2E tests for excluding the post being viewed - `tests/e2e/viewport-placeholder.spec.js` - E2E tests for lazy viewport placeholder rendering ## Testing Environment diff --git a/README.md b/README.md index 0ed2e0c..e0c687f 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,21 @@ 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). -### 4. Multiple Post Templates +### 4. Exclude Current Post + +Enable this option on a Query Loop that does not inherit the query to leave out the post being viewed, for example suggested posts on a single post template. The loop still shows its full number of posts. + +The loop avoids `post__not_in`, which scales poorly on large sites. On the first page it fetches one extra post and drops the current post, or the spare post, in PHP. Queries that need exact counts, such as later pages and the loop's pagination blocks, still use `post__not_in`. + +### 5. 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. -### 5. Query ID Deduplication +### 6. 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. -### 6. Query Presets +### 7. Query Presets Register custom query configurations in PHP that can be selected from a dropdown in the block editor. This allows developers to create reusable, dynamic queries (like "Related Articles" or "Trending Posts") that content editors can easily apply to any Query Loop block. @@ -37,7 +43,7 @@ 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. Sticky Posts +### 8. Sticky Posts Pin a hand-picked, ordered set of posts to the front of a query loop. Selected posts render first, in the order chosen in the editor; everything else follows in whatever order the block's own settings produce. @@ -98,6 +104,7 @@ See [tests/e2e/README.md](tests/e2e/README.md) for more details on the test setu - **Posts per page (Override)**: Only visible when inheriting query - enter a number to override posts per page, or leave empty to use default - **Hide on paginated pages**: Toggle to hide this block on page 2+ - **Exclude already displayed posts**: Toggle to avoid showing duplicate posts + - **Exclude current post**: Only visible when not inheriting query - toggle to leave out the post being viewed 4. For non-inherited queries with multiple Post Template blocks, select each `core/post-template` and set **Posts per template** to control how many posts each template shows 5. To pin posts to the front, open the **Sticky Posts** panel, search for a post and select it. Use the arrows to reorder pinned posts, or **Unpin** to remove one @@ -111,6 +118,7 @@ The plugin exposes an `hmQueryLoop` context object from `core/query` to `core/po perPage: number | undefined, // Custom posts per page value hideOnPaged: boolean, // Whether to hide on paginated pages excludeDisplayed: boolean, // Whether to exclude displayed posts + excludeCurrentPost: boolean, // Whether to exclude the post being viewed stickyPosts: number[] | undefined // Post IDs pinned to the front, in order } ``` diff --git a/hm-query-loop.php b/hm-query-loop.php index 05543ca..f73b1ff 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -45,7 +45,11 @@ 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 ); + + // Drop the current post from results fetched with one post to spare, before + // they are tracked as displayed. + add_filter( 'the_posts', __NAMESPACE__ . '\\remove_current_post_from_results', 9, 2 ); // Hook into the_posts to track displayed posts and limit post-template posts. add_filter( 'the_posts', __NAMESPACE__ . '\\track_displayed_posts', 10, 2 ); @@ -389,11 +393,12 @@ 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 query loop. * @return array The modified query vars. */ -function filter_query_loop_block_query_vars( $query, WP_Block $block ) { +function filter_query_loop_block_query_vars( $query, WP_Block $block, $page = 1 ) { if ( $block->name === 'core/post-template' ) { global $query_loop_post_template_per_pages; @@ -432,9 +437,113 @@ function filter_query_loop_block_query_vars( $query, WP_Block $block ) { $query_loop_post_template_per_pages[ $query_id ][] = $post_template_per_page; $attrs['hmQueryLoop']['excludeDisplayedForCurrentLoop'] = $query_id; - return modify_query_from_block_attrs( $query, $attrs ); + $query = modify_query_from_block_attrs( $query, $attrs ); + + // The post template's own first page can over-fetch instead of + // excluding in SQL. + return exclude_current_post( $query, $attrs['hmQueryLoop'], (int) $page === 1 ); } - return modify_query_from_block_attrs( $query, $block->context ); + + $query = modify_query_from_block_attrs( $query, $block->context ); + + // Other blocks, such as pagination, query the loop to count its pages, so + // they need the real exclusion for those counts to be right. + return exclude_current_post( $query, $block->context['hmQueryLoop'] ?? [], false ); +} + +/** + * Get the post being viewed, if this is a single post, page or attachment view. + * + * @return int Post ID, or 0 when not viewing a single post. + */ +function get_current_post_id(): int { + return is_singular() ? (int) get_queried_object_id() : 0; +} + +/** + * Exclude the post being viewed from a query loop. + * + * NOT IN queries scale poorly, so on the first page of a post template the + * query fetches one extra post instead, and + * remove_current_post_from_results() drops the current post, or the spare post + * if the current post was not in the results. + * + * @param array $query Query args for the query loop. + * @param array $settings The loop's hmQueryLoop settings. + * @param bool $over_fetch Whether this query may fetch one extra post instead + * of excluding the current post in SQL. + * @return array Modified query args. + */ +function exclude_current_post( $query, $settings, $over_fetch ) { + if ( empty( $settings['excludeCurrentPost'] ) ) { + return $query; + } + + $current_post_id = get_current_post_id(); + if ( ! $current_post_id ) { + return $query; + } + + // A list of posts to include can be filtered in PHP, without NOT IN. + if ( ! empty( $query['post__in'] ) && is_array( $query['post__in'] ) ) { + $post__in = array_values( array_diff( $query['post__in'], [ $current_post_id ] ) ); + + // An empty post__in means no restriction, so ask for a post that + // cannot exist. + $query['post__in'] = $post__in ?: [ 0 ]; + return $query; + } + + $per_page = (int) ( $query['posts_per_page'] ?? get_option( 'posts_per_page', 10 ) ); + + if ( ! $over_fetch && $per_page > 0 ) { + return exclude_posts_from_query( $query, [ $current_post_id ] ); + } + + // Unlimited queries return the current post anyway, so only drop it. + if ( $per_page > 0 ) { + $query['posts_per_page'] = $per_page + 1; + } + + $query['hm_query_loop_exclude_current_post'] = [ + 'post_id' => $current_post_id, + 'per_page' => $per_page, + ]; + + return $query; +} + +/** + * Remove the current post from a query loop's results. + * + * Pairs with exclude_current_post(), which fetches one post more than the + * loop shows. The current post is removed if present, and the list is then + * trimmed back to the loop's own post count. + * + * @param array $posts Array of post objects. + * @param WP_Query $query The WP_Query instance. + * @return array Array of post objects. + */ +function remove_current_post_from_results( $posts, $query ) { + $exclusion = $query->get( 'hm_query_loop_exclude_current_post' ); + if ( empty( $exclusion ) || ! is_array( $posts ) ) { + return $posts; + } + + $posts = array_values( + array_filter( + $posts, + function ( $post ) use ( $exclusion ) { + return (int) ( $post->ID ?? $post ) !== $exclusion['post_id']; + } + ) + ); + + if ( $exclusion['per_page'] > 0 ) { + $posts = array_slice( $posts, 0, $exclusion['per_page'] ); + } + + return $posts; } /** diff --git a/src/index.js b/src/index.js index 24a477e..a7b6094 100644 --- a/src/index.js +++ b/src/index.js @@ -236,8 +236,13 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => { } const { hmQueryLoop = {}, query = {} } = attributes; - const { perPage, hideOnPaged, excludeDisplayed, useElasticPress } = - hmQueryLoop; + const { + perPage, + hideOnPaged, + excludeDisplayed, + excludeCurrentPost, + useElasticPress, + } = hmQueryLoop; const isInheritQuery = query.inherit || false; const maxPerPage = window.hmQueryLoopSettings?.postsPerPage || 10; @@ -361,6 +366,27 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => { } ) } /> + { ! isInheritQuery && ( + + setAttributes( { + hmQueryLoop: { + ...hmQueryLoop, + excludeCurrentPost: value, + }, + } ) + } + /> + ) } { elasticPressAvailable && ( { + test( 'should leave the post being viewed out of its own query loop, and still fill the loop', async ( { + page, + admin, + editor, + blockEditor, + } ) => { + const postTitle = 'Test exclude current post'; + const postsPerPage = parseInt( + wpCli( 'wp option get posts_per_page' ), + 10 + ); + + await admin.createNewPost( { postType: 'post' } ); + await page.waitForTimeout( 1500 ); + + // Dismiss any modals + const closeButton = page.getByRole( 'button', { name: 'Close' } ); + if ( + await closeButton + .isVisible( { timeout: 1000 } ) + .catch( () => false ) + ) { + await closeButton.click(); + } + + const canvas = page + .locator( 'iframe[name="editor-canvas"]' ) + .contentFrame(); + + await canvas.getByRole( 'textbox', { name: 'Add title' } ).click(); + await canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( postTitle ); + + await editor.openDocumentSettingsSidebar(); + await page.waitForTimeout( 500 ); + + // Insert a Query Loop block + await canvas + .getByRole( 'button', { name: 'Add default block' } ) + .click(); + await canvas + .getByRole( 'document', { name: 'Empty block; start writing or' } ) + .fill( '/query' ); + await page + .getByRole( 'option', { name: 'Query Loop' } ) + .first() + .click(); + await page.waitForTimeout( 1000 ); + + // Start with a blank layout + const startBlankButton = canvas.getByRole( 'button', { + name: 'Start blank', + } ); + if ( + await startBlankButton + .isVisible( { timeout: 2000 } ) + .catch( () => false ) + ) { + await startBlankButton.click(); + await page.waitForTimeout( 500 ); + } + + // Choose a pattern + const patternButton = canvas.getByRole( 'button', { + name: 'Title & Date', + } ); + if ( + await patternButton + .isVisible( { timeout: 2000 } ) + .catch( () => false ) + ) { + await patternButton.click(); + await page.waitForTimeout( 500 ); + } + + await blockEditor.selectBlock.byName( 'core/query', 0 ); + await blockEditor.queryBlock.setAsCustom(); + await blockEditor.queryBlock.openSettingsPanel(); + + await page + .getByRole( 'checkbox', { name: 'Exclude current post' } ) + .check(); + + await blockEditor.publishAndVisit(); + + // The theme's template has query loops of its own, so only read the + // loop inside the post content. + const loopTitles = await page + .locator( + '.wp-block-post-content .wp-block-post-template .wp-block-post-title' + ) + .allTextContents(); + console.log( 'Query loop titles:', loopTitles ); + + expect( loopTitles.map( ( title ) => title.trim() ) ).not.toContain( + postTitle + ); + expect( loopTitles.length ).toBe( postsPerPage ); + } ); +} ); From 1e430a031a79f63e5a1bfd6dfef30d0a910a0929 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Mon, 21 Sep 2026 14:10:27 +0100 Subject: [PATCH 3/3] Tests: delete the exclude current post test's post afterwards The database is only reset once per run, so the post and its query loop showed up in the posts per page and query preset specs' listings. --- tests/e2e/exclude-current-post.spec.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/e2e/exclude-current-post.spec.js b/tests/e2e/exclude-current-post.spec.js index efbdbf3..f7ec305 100644 --- a/tests/e2e/exclude-current-post.spec.js +++ b/tests/e2e/exclude-current-post.spec.js @@ -8,6 +8,17 @@ const { test, expect, wpCli } = require( './fixtures' ); test.describe( 'Exclude current post', () => { + // The database is only reset once per run. Left in place, this post and + // its query loop would show up in other specs' post listings. + test.afterEach( () => { + const postId = wpCli( + 'wp post list --post_type=post --name=test-exclude-current-post --field=ID' + ); + if ( /^\d+$/.test( postId ) ) { + wpCli( `wp post delete ${ postId } --force` ); + } + } ); + test( 'should leave the post being viewed out of its own query loop, and still fill the loop', async ( { page, admin,