Skip to content
Draft
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
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ The plugin provides a PHP API for registering custom query presets that can be s
'related_articles', // Unique identifier
'Related Articles', // Human-readable label
function( $query_vars, $context ) {
// $context includes: post_id, is_rest, block (perPage, page)
// $context includes: post_id, is_rest, block_instance, block (perPage, page)
// $context['block'] is pagination metadata, NOT the block — the block is
// $context['block_instance'], e.g. $context['block_instance']?->context['termId'] ?? 0
// Modify and return $query_vars
return $query_vars;
}
Expand All @@ -113,9 +115,13 @@ The plugin provides a PHP API for registering custom query presets that can be s
1. Presets are registered via PHP callbacks that receive query args and context
2. The preset selector appears in the block editor when presets are registered
3. REST API hooks are automatically added for all public post types via `rest_{$post_type}_collection_params` and `rest_{$post_type}_query`
4. Frontend queries are modified via `query_loop_block_query_vars` filter
4. Frontend queries are modified via `query_loop_block_query_vars` filter, which puts the `WP_Block` into the context as `block_instance`
5. The selected preset is stored in `query.hmPreset` block attribute

`block_instance` is null on REST. The editor preview queries the collection endpoint and never renders a block, so there is no instance to carry. See `modify_rest_query_for_preset()`. Both paths set the key explicitly, so the context has one shape.

`post_id` comes from `$block->context['postId']` where the block has it, falling back to `get_the_ID()`. The block context is what core resolved for that block; `get_the_ID()` is global loop state, and the two can diverge.

## Key Files

- `hm-query-loop.php` - Main plugin file with all PHP hooks and query modification logic
Expand All @@ -124,6 +130,7 @@ The plugin provides a PHP API for registering custom query presets that can be s
- `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/php/query-presets-test.php` - Unit tests for the preset callback contract
- `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
Expand Down
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,12 @@ add_action( 'init', function() {
'Related Articles', // Label shown in dropdown
function( $query_vars, $context ) {
// $context includes:
// - post_id: Current post ID (useful for related content)
// - post_id: The post the block is rendering for ($block->context['postId'],
// falling back to get_the_ID())
// - is_rest: Boolean, true when called from REST API (editor)
// - block: Array with perPage and page values
// - block_instance: The WP_Block being rendered, or null on REST
// - block: Array with perPage and page values. Despite the name this is
// pagination metadata, not the block — that is block_instance.

$related_ids = get_post_meta( $context['post_id'], 'related_posts', true );

Expand All @@ -164,6 +167,47 @@ add_action( 'init', function() {
});
```

### Reading block context

`$context['block_instance']` is the `WP_Block` being rendered, so a preset can read
anything core puts in its context — `postId`, or the `termId` and `taxonomy` a Term
Template provides to the blocks inside it. This example is illustrative — the
plugin ships no presets of its own:

```php
\HM\QueryLoop\QueryPresets\register_query_preset(
'posts_in_this_term',
'Posts in this term',
function( $query_vars, $context ) {
$term_id = $context['block_instance']?->context['termId'] ?? 0;

if ( ! $term_id ) {
return $query_vars;
}

$query_vars['tax_query'] = [
[
'taxonomy' => $context['block_instance']->context['taxonomy'] ?? 'category',
'terms' => [ $term_id ],
],
];

return $query_vars;
}
);
```

It is named `block_instance` because `$context['block']` was there first and is not
the block: it holds `perPage` and `page`. Renaming that would break presets already
reading it, so both keys stay.

`block_instance` is null on REST requests. The editor preview fetches posts from the
collection endpoint rather than rendering the block, so there is no block instance to
carry. A preset that depends on block context behaves differently in the editor
preview from the front end. Check `block_instance` before using it, and return
`$query_vars` unchanged when it is null. The key is always present, so `?->` reads it
safely and no `isset()` check is needed.

**Available Functions:**

- `\HM\QueryLoop\QueryPresets\register_query_preset( $name, $label, $callback )` - Register a preset
Expand Down
45 changes: 31 additions & 14 deletions inc/query-presets.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

namespace HM\QueryLoop\QueryPresets;

use WP_Block;

/**
* Registered query presets.
*
Expand All @@ -24,7 +26,11 @@
* @param string $label Human-readable label for the preset (e.g., 'Related Articles').
* @param callable $callback Function that receives query args and block context, returns modified query args.
* Signature: function(array $query_vars, array $context): array
* Context includes: 'post_id' (current post), 'block' (block attributes), 'is_rest' (bool).
* Context includes: 'post_id' (the post the block is rendering for),
* 'is_rest' (bool), 'block_instance' (the WP_Block, or null), and 'block' —
* which despite the name is pagination metadata ('perPage', 'page').
* 'block_instance' is null on REST requests, because the editor preview
* queries the collection endpoint directly: no block is being rendered.
* @return bool True on success, false if preset already exists.
*/
function register_query_preset( string $name, string $label, callable $callback ): bool {
Expand Down Expand Up @@ -94,7 +100,8 @@ function get_query_preset( string $name ): ?array {
*
* @param string $name Preset identifier.
* @param array $query_vars Current query arguments.
* @param array $context Additional context (post_id, block, is_rest).
* @param array $context Additional context (post_id, is_rest, block_instance, block). Note
* that 'block' is pagination metadata; 'block_instance' is the WP_Block.
* @return array Modified query arguments.
*/
function apply_query_preset( string $name, array $query_vars, array $context = [] ): array {
Expand Down Expand Up @@ -182,9 +189,13 @@ function modify_rest_query_for_preset( array $args, \WP_REST_Request $request ):
}

$context = [
'post_id' => $request->get_param( 'post_id' ) ?? get_the_ID() ?? 0,
'is_rest' => true,
'block' => [
'post_id' => $request->get_param( 'post_id' ) ?? get_the_ID() ?? 0,
'is_rest' => true,
// Set explicitly, so the context has one shape and presets can read it with
// `?->` and no isset() check. REST renders no block: the editor preview
// queries the collection endpoint.
'block_instance' => null,
'block' => [
'perPage' => $request->get_param( 'per_page' ),
],
];
Expand All @@ -195,24 +206,30 @@ function modify_rest_query_for_preset( array $args, \WP_REST_Request $request ):
/**
* Filter query vars for the Query Loop block on the frontend.
*
* @param array $query_vars Existing query variables.
* @param \WP_Block $block Block instance.
* @param int $page Current page number.
* @param array $query_vars Existing query variables.
* @param WP_Block $block Block instance.
* @param int $page Current page number.
* @return array Modified query variables.
*/
function filter_query_loop_block_query_vars( array $query_vars, \WP_Block $block, int $page ): array {
$context = $block->context ?? [];
$query_attr = $context['query'] ?? [];
function filter_query_loop_block_query_vars( array $query_vars, WP_Block $block, int $page ): array {
$block_context = $block->context ?? [];
$query_attr = $block_context['query'] ?? [];
$preset_name = $query_attr['hmPreset'] ?? '';

if ( empty( $preset_name ) ) {
return $query_vars;
}

$context = [
'post_id' => get_the_ID() ?? 0,
'is_rest' => false,
'block' => [
// Prefer what core resolved for this block over global loop state: the two
// agree on a singular template, and diverge where the global post is not
// what the block is rendering for.
'post_id' => $block_context['postId'] ?? get_the_ID() ?? 0,
'is_rest' => false,
// Presets need the block itself to reach core's context — termId and taxonomy
// inside a Term Template, postId, and so on. 'block' below is not that.
'block_instance' => $block,
'block' => [
'perPage' => $query_vars['posts_per_page'] ?? get_option( 'posts_per_page', 10 ),
'page' => $page,
],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +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:php": "php tests/php/deferred-exclusions-test.php && php tests/php/query-presets-test.php",
"test:e2e": "wp-scripts test-playwright",
"test:e2e:debug": "wp-scripts test-playwright --debug",
"test:e2e:watch": "wp-scripts test-playwright --watch"
Expand Down
Loading
Loading