Skip to content
Open
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
5 changes: 2 additions & 3 deletions .github/workflows/e2e-latest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]'
Comment on lines +35 to 37

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this specific one the default may as well be nightly or 7.1 rather than 6.9 here while we're bringing things up to date

# Core trunk is already covered by the nightly lane on every push.
experimental_matrix: '[]'
Expand Down
44 changes: 30 additions & 14 deletions .github/workflows/playwright-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -148,23 +163,24 @@ 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
# keeps resolving: the per-version lanes publish names ("WP 7.1") that
# 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" ]
20 changes: 18 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +39,7 @@ The plugin exposes an `hmQueryLoop` context object from `core/query` to `core/po
hideOnPaged: boolean, // Whether to hide on paginated pages
excludeDisplayed: boolean, // Whether to exclude displayed posts
useElasticPress: boolean, // Whether to route query through ElasticPress (only shown when EP is active)
stickyPosts: number[] | undefined // Post IDs pinned to the front, in order
}
```

Expand All @@ -59,7 +61,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: 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`)
- 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 `<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 All @@ -71,7 +84,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:
Expand Down Expand Up @@ -107,7 +120,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
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -37,7 +41,13 @@ 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
### 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 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

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 @@ -70,6 +80,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
Expand Down
Loading
Loading