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/CLAUDE.md b/CLAUDE.md index 58d5918..94fda98 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 @@ -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 } ``` @@ -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 `` 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 +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: @@ -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 diff --git a/README.md b/README.md index 0ed2e0c..be173c4 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,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. @@ -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 diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..0717ed7 --- /dev/null +++ b/composer.lock @@ -0,0 +1,972 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "726df63921e8e5bb82378a6422f46a5d", + "packages": [ + { + "name": "composer/installers", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/composer/installers.git", + "reference": "12fb2dfe5e16183de69e784a7b84046c43d97e8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/installers/zipball/12fb2dfe5e16183de69e784a7b84046c43d97e8e", + "reference": "12fb2dfe5e16183de69e784a7b84046c43d97e8e", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "composer/composer": "^1.10.27 || ^2.7", + "composer/semver": "^1.7.2 || ^3.4.0", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-phpunit": "^1", + "symfony/phpunit-bridge": "^7.1.1", + "symfony/process": "^5 || ^6 || ^7" + }, + "type": "composer-plugin", + "extra": { + "class": "Composer\\Installers\\Plugin", + "branch-alias": { + "dev-main": "2.x-dev" + }, + "plugin-modifies-install-path": true + }, + "autoload": { + "psr-4": { + "Composer\\Installers\\": "src/Composer/Installers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyle Robinson Young", + "email": "kyle@dontkry.com", + "homepage": "https://github.com/shama" + } + ], + "description": "A multi-framework Composer library installer", + "homepage": "https://composer.github.io/installers/", + "keywords": [ + "Dolibarr", + "Eliasis", + "Hurad", + "ImageCMS", + "Kanboard", + "Lan Management System", + "MODX Evo", + "MantisBT", + "Mautic", + "Maya", + "OXID", + "Plentymarkets", + "Porto", + "RadPHP", + "SMF", + "Starbug", + "Thelia", + "Whmcs", + "WolfCMS", + "agl", + "annotatecms", + "attogram", + "bitrix", + "cakephp", + "chef", + "cockpit", + "codeigniter", + "concrete5", + "concreteCMS", + "croogo", + "dokuwiki", + "drupal", + "eZ Platform", + "elgg", + "expressionengine", + "fuelphp", + "grav", + "installer", + "itop", + "known", + "kohana", + "laravel", + "lavalite", + "lithium", + "magento", + "majima", + "mako", + "matomo", + "mediawiki", + "miaoxing", + "modulework", + "modx", + "moodle", + "osclass", + "pantheon", + "phpbb", + "piwik", + "ppi", + "processwire", + "puppet", + "pxcms", + "reindex", + "roundcube", + "shopware", + "silverstripe", + "sydes", + "sylius", + "tastyigniter", + "wordpress", + "yawik", + "zend", + "zikula" + ], + "support": { + "issues": "https://github.com/composer/installers/issues", + "source": "https://github.com/composer/installers/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-06-24T20:46:46+00:00" + } + ], + "packages-dev": [ + { + "name": "automattic/vipwpcs", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/Automattic/VIP-Coding-Standards.git", + "reference": "9c47cd036754e0e5f354a9914568f052043c3f30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Automattic/VIP-Coding-Standards/zipball/9c47cd036754e0e5f354a9914568f052043c3f30", + "reference": "9c47cd036754e0e5f354a9914568f052043c3f30", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "sirbrillig/phpcs-variable-analysis": "^2.13.0", + "squizlabs/php_codesniffer": "^3.13.5", + "wp-coding-standards/wpcs": "^3.4.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9", + "phpcsstandards/phpcsdevtools": "^1.2.3", + "phpunit/phpunit": "^9" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/Automattic/VIP-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress VIP minimum coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/Automattic/VIP-Coding-Standards/issues", + "source": "https://github.com/Automattic/VIP-Coding-Standards", + "wiki": "https://github.com/Automattic/VIP-Coding-Standards/wiki" + }, + "time": "2026-07-27T14:33:48+00:00" + }, + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v0.7.2", + "source": { + "type": "git", + "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git", + "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", + "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "*", + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpcompatibility/php-compatibility": "^9.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "franck.nijhof@dealerdirect.com", + "homepage": "http://www.frenck.nl", + "role": "Developer / IT Manager" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "homepage": "http://www.dealerdirect.com", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues", + "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer" + }, + "time": "2022-02-04T12:51:07+00:00" + }, + { + "name": "fig-r/psr2r-sniffer", + "version": "0.5.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig-rectified/psr2r-sniffer.git", + "reference": "7eb462bcf19abcae122855a6d79cc8f768c77880" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig-rectified/psr2r-sniffer/zipball/7eb462bcf19abcae122855a6d79cc8f768c77880", + "reference": "7eb462bcf19abcae122855a6d79cc8f768c77880", + "shasum": "" + }, + "require": { + "php": ">=5.4.16", + "squizlabs/php_codesniffer": "^3.0" + }, + "bin": [ + "bin/tokenize", + "bin/sniff" + ], + "type": "phpcodesniffer-standard", + "autoload": { + "psr-4": { + "PSR2R\\": "PSR2R" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Scherer", + "homepage": "http://www.dereuromark.de", + "role": "Contributor" + } + ], + "description": "Code-Sniffer, Auto-Fixer and Tokenizer for PSR2-R", + "keywords": [ + "codesniffer", + "cs" + ], + "support": { + "issues": "https://github.com/php-fig-rectified/psr2r-sniffer/issues", + "source": "https://github.com/php-fig-rectified/psr2r-sniffer/tree/0.5.2" + }, + "time": "2019-07-30T11:13:07+00:00" + }, + { + "name": "humanmade/coding-standards", + "version": "v2.5.0", + "source": { + "type": "git", + "url": "https://github.com/humanmade/coding-standards.git", + "reference": "a22e869db192762bdd95212b76e4cdcb5a85318e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/humanmade/coding-standards/zipball/a22e869db192762bdd95212b76e4cdcb5a85318e", + "reference": "a22e869db192762bdd95212b76e4cdcb5a85318e", + "shasum": "" + }, + "require": { + "automattic/vipwpcs": "^3.0.0", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", + "fig-r/psr2r-sniffer": "^0.5.0", + "php": ">=8.2", + "phpcompatibility/phpcompatibility-wp": "^2.0.0", + "squizlabs/php_codesniffer": "^3.13.4", + "wp-coding-standards/wpcs": "^3.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.2" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "description": "Human Made Coding Standards", + "support": { + "issues": "https://github.com/humanmade/coding-standards/issues", + "source": "https://github.com/humanmade/coding-standards/tree/v2.5.0" + }, + "time": "2026-07-10T15:56:53+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-paragonie", + "version": "1.3.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "paragonie/random_compat": "dev-master", + "paragonie/sodium_compat": "dev-master" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "paragonie", + "phpcs", + "polyfill", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-09-19T17:43:28+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-wp", + "version": "2.1.8", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-10-18T00:05:59+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-07-27T11:13:17+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-07-27T10:28:41+00:00" + }, + { + "name": "sirbrillig/phpcs-variable-analysis", + "version": "v2.13.0", + "source": { + "type": "git", + "url": "https://github.com/sirbrillig/phpcs-variable-analysis.git", + "reference": "a15e970b8a0bf64cfa5e86d941f5e6b08855f369" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sirbrillig/phpcs-variable-analysis/zipball/a15e970b8a0bf64cfa5e86d941f5e6b08855f369", + "reference": "a15e970b8a0bf64cfa5e86d941f5e6b08855f369", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "squizlabs/php_codesniffer": "^3.5.7 || ^4.0.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.0", + "phpstan/phpstan": "^1.7 || ^2.0", + "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.5 || ^7.0 || ^8.0 || ^9.0 || ^10.5.32 || ^11.3.3", + "vimeo/psalm": "^0.2 || ^0.3 || ^1.1 || ^4.24 || ^5.0 || ^6.0 || ^7.0" + }, + "type": "phpcodesniffer-standard", + "autoload": { + "psr-4": { + "VariableAnalysis\\": "VariableAnalysis/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Sam Graham", + "email": "php-codesniffer-variableanalysis@illusori.co.uk" + }, + { + "name": "Payton Swick", + "email": "payton@foolord.com" + } + ], + "description": "A PHPCS sniff to detect problems with variables.", + "keywords": [ + "phpcs", + "static analysis" + ], + "support": { + "issues": "https://github.com/sirbrillig/phpcs-variable-analysis/issues", + "source": "https://github.com/sirbrillig/phpcs-variable-analysis", + "wiki": "https://github.com/sirbrillig/phpcs-variable-analysis/wiki" + }, + "time": "2025-09-30T22:22:48+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.6", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-08-06T00:17:32+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/php_codesniffer", + "type": "custom" + } + ], + "time": "2026-07-27T11:53:23+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/docs/query-caching.md b/docs/query-caching.md new file mode 100644 index 0000000..4104ffc --- /dev/null +++ b/docs/query-caching.md @@ -0,0 +1,360 @@ +# 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 | +|---|---| +| 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 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 + +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'] ] + ); + } +); +``` + +## Measured + +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 single query loop with no plugin settings at all, across 100 URLs:** + +| | 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 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, the worst case for the over-fetching, 15–20 +interleaved runs per build: + +| | 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, +and the query count still falls because the post templates share one query. + +**Rendered output**, compared post by post across 25 URLs per fixture: + +| Fixture | WP 6.9 | WP 7.1 | +|---|---|---| +| 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 + +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 05543ca..ad84b5f 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -30,6 +30,9 @@ // Load sticky posts functionality. require_once HM_QUERY_LOOP_PATH . 'inc/sticky-posts.php'; +// Load PHP-side post exclusions. +require_once HM_QUERY_LOOP_PATH . 'inc/deferred-exclusions.php'; + /** * Initialize the plugin. */ @@ -45,7 +48,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 ); @@ -58,6 +61,9 @@ function init() { // Initialize sticky posts functionality. StickyPosts\bootstrap(); + + // Initialize PHP-side post exclusions. + DeferredExclusions\init(); } add_action( 'init', __NAMESPACE__ . '\\init', 9 ); @@ -156,6 +162,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. * @@ -389,52 +408,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 ] ); - $attrs['hmQueryLoop']['excludeDisplayedForCurrentLoop'] = $query_id; - return modify_query_from_block_attrs( $query, $attrs ); + $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 ); +} + +/** + * 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 ]; } /** @@ -469,27 +504,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. @@ -502,28 +547,24 @@ 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 ); - } - } - // Carry pinned post IDs through to the ORDER BY clause. This is an // ordering concern rather than a query var, so it is stashed on the query - // and read back in StickyPosts\apply_sticky_order(). + // and read back in StickyPosts\apply_sticky_order(). Unlike this plugin's + // own bookkeeping it has to reach WP_Query, but it only repeats IDs that + // the ORDER BY already carries, so it adds no cache-key fragmentation. $sticky_posts = StickyPosts\normalize_ids( $settings['stickyPosts'] ?? [] ); if ( ! empty( $sticky_posts ) ) { $query[ StickyPosts\QUERY_VAR ] = $sticky_posts; } - // 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'] ); + // 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(); + 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 ); } } @@ -538,16 +579,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..915ba20 --- /dev/null +++ b/inc/deferred-exclusions.php @@ -0,0 +1,480 @@ + + */ +$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'] ); + + // 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'] ) ) { + $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/e2e/fixtures.js b/tests/e2e/fixtures.js index bf1361d..4f66ca2 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,159 @@ export const test = base.extend( { * @param use */ blockEditor: async ( { admin, editor, page }, use ) => { + /** + * 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 clickIfPresent( locator, timeout = 3000 ) { + try { + await locator.first().waitFor( { state: 'visible', timeout } ); + } catch ( error ) { + return false; + } + + await locator.first().click(); + await page.waitForTimeout( 500 ); + return true; + } + + /** + * 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; + } + } + } + + /** + * 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( () => [] ); + + // 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( ' ' ); + } + + /** + * 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,128 +218,181 @@ 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` - ); + const allRoutes = siteEditorRoutes( templateId ); + const routes = + resolvedSiteEditorRoute === null + ? allRoutes + : [ allRoutes[ resolvedSiteEditorRoute ] ]; - // Wait for site editor to load - await page.waitForSelector( - '.edit-site-layout, iframe[name="editor-canvas"]', - { timeout: 15000 } - ); - - // 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 ); + if ( await isEditingTemplate( templateId, 15000 ) ) { + resolvedSiteEditorRoute = allRoutes.indexOf( route ); - 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 ); + // 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; } } - // 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 ) { + // 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( + ' | ' + ) }` + ); + } + } + + // 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 clickIfPresent( + page.getByRole( 'tab', { name: 'Block' } ), + 10000 + ) + ) { await page - .getByRole( 'button', { name: 'Settings' } ) - .first() - .click(); - await settingsRegion.waitFor( { timeout: 15000 } ); + .getByRole( 'tab', { name: 'Block', selected: true } ) + .waitFor( { state: 'visible', timeout: 10000 } ) + .catch( () => {} ); } - // 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' } ); + // 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 blockTab - .isVisible( { timeout: 2000 } ) - .catch( () => false ) + await clickIfPresent( + settingsRegion.getByRole( 'button', { + name: 'Edit pattern', + } ), + 2000 + ) ) { - await blockTab.click(); - await page.waitForTimeout( 300 ); + await page.waitForTimeout( 500 ); } await page.waitForTimeout( 1000 ); }, /** - * 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(); + + try { + await toggle.waitFor( { + state: 'visible', + timeout: 15000, + } ); + } catch ( error ) { + throw new Error( + `No "${ panelTitle }" panel in the inspector. ${ await describeInspector() }` + ); + } + + if ( + ( await toggle.getAttribute( 'aria-expanded' ) ) !== 'true' + ) { + await toggle.click(); await page.waitForTimeout( 300 ); } }, @@ -196,23 +437,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 ); }, @@ -243,24 +507,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 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(); 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', 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 ); +}