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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions hm-query-loop.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
// Load query presets functionality.
require_once HM_QUERY_LOOP_PATH . 'inc/query-presets.php';

// Load preload paths functionality.
require_once HM_QUERY_LOOP_PATH . 'inc/preload-paths.php';

/**
* Initialize the plugin.
*/
Expand All @@ -52,6 +55,9 @@ function init() {

// Initialize query presets functionality.
QueryPresets\init();

// Register REST preload paths for query loop blocks.
PreloadPaths\register();
}

add_action( 'init', __NAMESPACE__ . '\\init', 9 );
Expand Down
158 changes: 158 additions & 0 deletions inc/preload-paths.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php
/**
* Preload REST data for query loop blocks in the block editor.
*
* On post-editor load, parse the post content for non-inherited core/query
* blocks, derive the exact REST URL that core/post-template's
* getEntityRecords() call will produce, and append it to the preload paths
* so apiFetch's preloading middleware can serve the response from cache
* without an HTTP round-trip.
*
* @package HM\QueryLoop
*/

namespace HM\QueryLoop\PreloadPaths;

/**
* Register the preload paths hook.
*/
function register() {
add_filter(
'block_editor_rest_api_preload_paths',
__NAMESPACE__ . '\\add_query_loop_paths',
10,
2
);
}

/**
* Add REST preload paths for each non-inherited query loop in the post.
*
* @param array $paths Existing preload paths.
* @param \WP_Block_Editor_Context $context Block editor context.
* @return array Modified paths.
*/
function add_query_loop_paths( array $paths, $context ) {
if ( ! isset( $context->post ) || ! ( $context->post instanceof \WP_Post ) ) {
return $paths;
}

$blocks = parse_blocks( $context->post->post_content );
$query_blocks = find_query_blocks( $blocks );

// Cap at 10 to avoid bloating the preload payload (Gutenberg issue #27370).
$query_blocks = array_slice( $query_blocks, 0, 10 );

foreach ( $query_blocks as $block ) {
$path = build_rest_path( $block );
if ( $path ) {
$paths[] = $path;
}
}

return $paths;
}

/**
* Recursively find all non-inherited core/query blocks in a block tree.
*
* @param array $blocks Parsed blocks.
* @return array Flat array of core/query block arrays.
*/
function find_query_blocks( array $blocks ) {
$result = [];
foreach ( $blocks as $block ) {
if ( 'core/query' === $block['blockName'] ) {
$inherit = $block['attrs']['query']['inherit'] ?? false;
if ( ! $inherit ) {
$result[] = $block;
}
}
if ( ! empty( $block['innerBlocks'] ) ) {
$result = array_merge( $result, find_query_blocks( $block['innerBlocks'] ) );
}
}
return $result;
}

/**
* Build a REST API path for a query loop block.
*
* The resulting URL mirrors what core/post-template's getEntityRecords()
* produces so the apiFetch preloading middleware matches it. See:
* packages/block-library/src/post-template/edit.js in Gutenberg.
*
* Limitations (intentional for v1):
* - Skips loops with taxQuery (taxonomy rest_base mapping is complex).
* - Skips loops with excludeDisplayed (depends on runtime render order).
* - Skips array params (exclude, parents, format) to avoid PHP/JS
* query-string serialisation mismatches.
*
* @param array $block Parsed block array.
* @return string|null REST path string or null if the block should not be preloaded.
*/
function build_rest_path( array $block ) {
$query = $block['attrs']['query'] ?? [];
$hm_loop = $block['attrs']['hmQueryLoop'] ?? [];

// Skip excludeDisplayed — query depends on previous loops' render output.
if ( ! empty( $hm_loop['excludeDisplayed'] ) ) {
return null;
}

// Skip taxQuery — requires resolving taxonomy rest_base per registered taxonomy.
if ( ! empty( $query['taxQuery'] ) ) {
return null;
}

// Resolve post type and REST base.
$post_type = sanitize_key( $query['postType'] ?? 'post' );
$post_type_obj = get_post_type_object( $post_type );
if ( ! $post_type_obj || empty( $post_type_obj->rest_base ) ) {
return null;
}
$rest_base = $post_type_obj->rest_base;

// core/post-template always passes offset (defaults to 0).
$offset = max( 0, (int) ( $query['offset'] ?? 0 ) );

// hmQueryLoop.perPage overrides query.perPage (used for inherited queries).
// Only include per_page when explicitly set — matches the JS condition `perPage > 0`.
$per_page = null;
if ( ! empty( $hm_loop['perPage'] ) && (int) $hm_loop['perPage'] > 0 ) {
$per_page = (int) $hm_loop['perPage'];
} elseif ( ! empty( $query['perPage'] ) && (int) $query['perPage'] > 0 ) {
$per_page = (int) $query['perPage'];
}

// Build params in the same shape as post-template/edit.js.
$params = [
'offset' => $offset,
'order' => in_array( $query['order'] ?? '', [ 'asc', 'desc' ], true )
? $query['order']
: 'desc',
'orderby' => sanitize_key( $query['orderBy'] ?? 'date' ),
];

if ( null !== $per_page ) {
$params['per_page'] = $per_page;
}

if ( ! empty( $query['author'] ) && is_numeric( $query['author'] ) ) {
$params['author'] = (int) $query['author'];
}

if ( ! empty( $query['search'] ) ) {
$params['search'] = sanitize_text_field( $query['search'] );
}

// Sticky post handling mirrors the JS logic exactly.
$sticky = $query['sticky'] ?? '';
if ( in_array( $sticky, [ 'exclude', 'only' ], true ) ) {
$params['sticky'] = $sticky === 'only' ? 'true' : 'false';
} elseif ( 'ignore' === $sticky ) {
$params['ignore_sticky'] = 'true';
}

return '/wp/v2/' . $rest_base . '?' . http_build_query( $params );
}
172 changes: 172 additions & 0 deletions tests/e2e/preload-paths.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/* eslint-disable no-console */
/**
* Test that REST API paths for query loop blocks are preloaded in the editor.
*
* The preload-paths feature prevents the editor from firing HTTP requests
* for posts on initial load by injecting the REST responses into the
* apiFetch preloading middleware during server-side editor bootstrap.
*/
const { test, expect } = require( './fixtures' );

test.describe( 'Query Loop REST Preloading', () => {
/**
* Create a page with a non-inherited Query Loop block (posts type, 5 per page,
* descending date order), publish it, then reopen it in the editor.
* Count /wp/v2/posts requests fired after the editor is interactive:
* with preloading active the count should be 0 for that loop's initial render.
*/
test( 'should preload /wp/v2/posts REST path for query loop blocks', async ( {
page,
admin,
editor,
} ) => {
// --- Step 1: Create and publish a page with a Query Loop block ---
await admin.createNewPost( { postType: 'page' } );
await page.waitForTimeout( 1500 );

// Dismiss modals if any.
const closeButton = page.getByRole( 'button', { name: 'Close' } );
if ( await closeButton.isVisible( { timeout: 1000 } ).catch( () => false ) ) {
await closeButton.click();
}

const canvas = page.locator( 'iframe[name="editor-canvas"]' ).contentFrame();

await canvas.getByRole( 'textbox', { name: 'Add title' } ).click();
await canvas.getByRole( 'textbox', { name: 'Add title' } ).fill( 'Preload test page' );

// Insert a Query Loop block.
await canvas.getByRole( 'button', { name: 'Add default block' } ).click();
await canvas.getByRole( 'document', { name: 'Empty block; start writing or' } ).fill( '/query' );
await page.getByRole( 'option', { name: 'Query Loop' } ).click();
await page.waitForTimeout( 1000 );

// Choose a layout / start blank if prompted.
const chooseButton = page.getByRole( 'button', { name: /choose|start blank/i } );
if ( await chooseButton.isVisible( { timeout: 2000 } ).catch( () => false ) ) {
await chooseButton.first().click();
await page.waitForTimeout( 500 );
}

// Publish.
await editor.publishPost();
await page.waitForTimeout( 1500 );

// Get the post ID from the URL.
const url = page.url();
const postIdMatch = url.match( /[?&]post=(\d+)/ );
const postId = postIdMatch ? postIdMatch[ 1 ] : null;
expect( postId ).not.toBeNull();

console.log( 'Created page with ID:', postId );

// --- Step 2: Reopen the editor and intercept REST requests ---
// Track any /wp/v2/posts requests that fire AFTER the page navigation.
const postsRequests = [];
page.on( 'request', ( req ) => {
if (
req.url().includes( '/wp/v2/posts' ) &&
! req.url().includes( 'wp/v2/posts/' ) // exclude single-post endpoints
) {
postsRequests.push( req.url() );
console.log( 'REST request fired:', req.url() );
}
} );

// Navigate to the edit page (fresh load, no editor cache).
await page.goto( `/wp-admin/post.php?post=${ postId }&action=edit` );
await page.waitForLoadState( 'networkidle', { timeout: 30000 } );

// Give the editor a moment to render blocks.
await page.waitForTimeout( 3000 );

console.log( 'Total /wp/v2/posts requests on load:', postsRequests.length );
console.log( 'Requests:', postsRequests );

// With preloading active, the query loop's initial posts fetch should
// be served from the preload cache — no HTTP request fired.
expect( postsRequests.length ).toBe( 0 );
} );

test( 'should still fetch posts after query attributes change', async ( {
page,
admin,
editor,
} ) => {
// Create a page with a Query Loop block.
await admin.createNewPost( { postType: 'page' } );
await page.waitForTimeout( 1500 );

const closeButton = page.getByRole( 'button', { name: 'Close' } );
if ( await closeButton.isVisible( { timeout: 1000 } ).catch( () => false ) ) {
await closeButton.click();
}

const canvas = page.locator( 'iframe[name="editor-canvas"]' ).contentFrame();

await canvas.getByRole( 'textbox', { name: 'Add title' } ).click();
await canvas.getByRole( 'textbox', { name: 'Add title' } ).fill( 'Preload change test' );

await canvas.getByRole( 'button', { name: 'Add default block' } ).click();
await canvas.getByRole( 'document', { name: 'Empty block; start writing or' } ).fill( '/query' );
await page.getByRole( 'option', { name: 'Query Loop' } ).click();
await page.waitForTimeout( 1000 );

const chooseButton = page.getByRole( 'button', { name: /choose|start blank/i } );
if ( await chooseButton.isVisible( { timeout: 2000 } ).catch( () => false ) ) {
await chooseButton.first().click();
await page.waitForTimeout( 500 );
}

await editor.publishPost();
await page.waitForTimeout( 1500 );

const url = page.url();
const postIdMatch = url.match( /[?&]post=(\d+)/ );
const postId = postIdMatch ? postIdMatch[ 1 ] : null;
expect( postId ).not.toBeNull();

// Reload the editor.
await page.goto( `/wp-admin/post.php?post=${ postId }&action=edit` );
await page.waitForLoadState( 'networkidle', { timeout: 30000 } );
await page.waitForTimeout( 2000 );

// Start tracking requests AFTER initial load is complete.
const postsRequestsAfterLoad = [];
page.on( 'request', ( req ) => {
if (
req.url().includes( '/wp/v2/posts' ) &&
! req.url().includes( 'wp/v2/posts/' )
) {
postsRequestsAfterLoad.push( req.url() );
}
} );

// Click the Query Loop block to select it, then change its items-per-page.
const queryBlock = canvas.locator( '.wp-block-query' ).first();
if ( await queryBlock.isVisible( { timeout: 5000 } ).catch( () => false ) ) {
await queryBlock.click();
await page.waitForTimeout( 500 );

// Open the Query Loop inspector controls.
const settingsButton = page.getByRole( 'button', { name: 'Settings' } );
if ( await settingsButton.isVisible( { timeout: 1000 } ).catch( () => false ) ) {
await settingsButton.click();
}

// A query attribute change should trigger a fresh REST request
// (cache miss for the new params).
await page.waitForTimeout( 2000 );
}

// After attribute changes, the editor should eventually re-fetch.
// This test just confirms the block is still functional (doesn't hang
// after a cache miss). We don't assert exact request count here.
console.log( 'Requests after load/interaction:', postsRequestsAfterLoad.length );

// Block should still be visible (not broken by a cache miss).
await expect(
canvas.locator( '.wp-block-query' ).first()
).toBeVisible( { timeout: 10000 } );
} );
} );
Loading