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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
```
Expand All @@ -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 `<Placeholder>` 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.

Expand Down Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -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
}
```
Expand Down
119 changes: 114 additions & 5 deletions hm-query-loop.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

/**
Expand Down
30 changes: 28 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -361,6 +366,27 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => {
} )
}
/>
{ ! isInheritQuery && (
<ToggleControl
label={ __(
'Exclude current post',
'hm-query-loop'
) }
help={ __(
'Leave out the post being viewed, for example in suggested posts on a single post.',
'hm-query-loop'
) }
checked={ !! excludeCurrentPost }
onChange={ ( value ) =>
setAttributes( {
hmQueryLoop: {
...hmQueryLoop,
excludeCurrentPost: value,
},
} )
}
/>
) }
{ elasticPressAvailable && (
<ToggleControl
label={ __(
Expand Down
122 changes: 122 additions & 0 deletions tests/e2e/exclude-current-post.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/* eslint-disable no-console */
/**
* Test that a query loop can leave out the post being viewed.
*
* The post created here is the newest post, so a loop of the latest posts
* would list it first unless the "Exclude current post" setting removes it.
*/
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,
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 );
} );
} );
2 changes: 1 addition & 1 deletion tests/e2e/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
Loading