Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ The plugin handles two different query scenarios:
- Global `$displayed_post_ids` array accumulates IDs from rendered query loops
- Subsequent query loops with `excludeDisplayed` enabled filter out tracked IDs via `post__not_in`

### Editor Viewport Placeholder (Lazy Rendering)
`withViewportPlaceholder` HOC (registered last, so it wraps the plugin's other `core/query` enhancements) replaces off-screen Query Loop blocks with a cheap `<Placeholder>` that fires no REST request. Mounting the real block triggers the core preview fetch, so on a page with many query loops this defers those requests until each block scrolls near the viewport. An `IntersectionObserver` — constructed from the target node's own `ownerDocument.defaultView` so it works whether or not the canvas is iframed — swaps in the real block on intersection (with a 300px `rootMargin` preload). Selecting a block (e.g. right after insertion or via List View) renders it immediately, and once rendered a block stays rendered (latched via state) so scrolling away neither discards edits nor refetches.

### Editor Preview Synchronization
`withPostTemplateStyles` HOC injects an inline `<style>` tag that hides posts outside each post template's slice in the editor preview using `nth-of-type` selectors. It accounts for both query-level `perPage` (inherited queries) and post-template-level `perPage` (multiple post templates), plus an offset for preceding templates.

Expand Down Expand Up @@ -111,6 +114,7 @@ The plugin provides a PHP API for registering custom query presets that can be s
- `tests/e2e/multiple-post-templates.spec.js` - E2E tests for multiple post templates
- `tests/e2e/unique-query-id.spec.js` - E2E tests for query ID deduplication
- `tests/e2e/exclude-with-post-in.spec.js` - E2E tests for exclusion with post__in queries
- `tests/e2e/viewport-placeholder.spec.js` - E2E tests for lazy viewport placeholder rendering

## Testing Environment

Expand Down
152 changes: 147 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,26 @@
* WordPress dependencies
*/
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';
import { createHigherOrderComponent, useMergeRefs } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
import { InspectorControls } from '@wordpress/block-editor';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import {
PanelBody,
ToggleControl,
TextControl,
SelectControl,
Placeholder,
Spinner,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { createContext, useContext, useEffect } from '@wordpress/element';
import {
createContext,
useContext,
useEffect,
useState,
useRef,
useCallback,
} from '@wordpress/element';

/**
* Styles
Expand Down Expand Up @@ -226,11 +235,13 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => {
}

const { hmQueryLoop = {}, query = {} } = attributes;
const { perPage, hideOnPaged, excludeDisplayed, useElasticPress } = hmQueryLoop;
const { perPage, hideOnPaged, excludeDisplayed, useElasticPress } =
hmQueryLoop;

const isInheritQuery = query.inherit || false;
const maxPerPage = window.hmQueryLoopSettings?.postsPerPage || 10;
const elasticPressAvailable = window.hmQueryLoopSettings?.elasticPressAvailable ?? false;
const elasticPressAvailable =
window.hmQueryLoopSettings?.elasticPressAvailable ?? false;

// Get available presets from PHP.
const availablePresets = window.hmQueryLoopPresets?.presets || [];
Expand Down Expand Up @@ -626,3 +637,134 @@ addFilter(
'hm-query-loop/with-post-template-styles',
withPostTemplateStyles
);

/**
* Placeholder shown in place of a Query Loop block until it scrolls into view.
*
* Rendering the real block edit component mounts the core Query Loop, which
* immediately fires a REST request to fetch its posts for the preview. On a
* page with many query loops every one of those requests is dispatched up
* front on editor load, which is slow and can hammer the server. This
* placeholder is cheap to render and issues no requests; the real block is
* only mounted once the placeholder intersects the viewport.
*
* The IntersectionObserver is created from the target node's own
* `defaultView` so the correct viewport is used whether or not the editor
* canvas is rendered inside an iframe (the site editor and the block-themed
* post editor both iframe the canvas). If IntersectionObserver is
* unavailable the block is rendered immediately as a safe fallback.
Comment on lines +644 to +655

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is my own personal pet peeve, but comment length inflation has been driving me up a wall.

Suggested change
* Rendering the real block edit component mounts the core Query Loop, which
* immediately fires a REST request to fetch its posts for the preview. On a
* page with many query loops every one of those requests is dispatched up
* front on editor load, which is slow and can hammer the server. This
* placeholder is cheap to render and issues no requests; the real block is
* only mounted once the placeholder intersects the viewport.
*
* The IntersectionObserver is created from the target node's own
* `defaultView` so the correct viewport is used whether or not the editor
* canvas is rendered inside an iframe (the site editor and the block-themed
* post editor both iframe the canvas). If IntersectionObserver is
* unavailable the block is rendered immediately as a safe fallback.
* The core Query Loop immediately requests its post via REST on render, which
* can lock up the editor and hammer the server on a page with many query loops.
*
* This component renders a cheap placeholder that issues no requests while
* the Query Loop block is outside of the viewport, then initializes on demand.
*
* Our IntersectionObserver is created from the target node's own `defaultView`
* so the correct viewport is used whether or not the editor is iframe'd.
* Blocks render as normal if IntersectionObserver is not available.

To me this communicates the same information more directly.

*
* @param {Object} props Component props.
* @param {Function} props.onEnterViewport Called once the placeholder is at
* or near the viewport.
* @return {Element} Placeholder element.
*/
function QueryLoopPlaceholder( { onEnterViewport } ) {
const observerRef = useRef();
const blockProps = useBlockProps( {
className: 'hm-query-loop-viewport-placeholder',
} );
const mergedRef = useMergeRefs( [ blockProps.ref, observerRef ] );

useEffect( () => {
const node = observerRef.current;
if ( ! node ) {
return;
}

const view = node.ownerDocument?.defaultView;
if ( ! view?.IntersectionObserver ) {
// No observer support: fall back to rendering the block now.
onEnterViewport();
return;
}

const observer = new view.IntersectionObserver(
( entries ) => {
if ( entries.some( ( entry ) => entry.isIntersecting ) ) {
onEnterViewport();
}
},
// Load a little before the block is actually on screen so the
// preview is ready by the time the user scrolls to it.
{ rootMargin: '300px 0px' }
);
observer.observe( node );

return () => observer.disconnect();
}, [ onEnterViewport ] );

return (
<div { ...blockProps } ref={ mergedRef }>
<Placeholder
label={ __( 'Query Loop', 'hm-query-loop' ) }
instructions={ __(
'The preview will load when this block scrolls into view.',
'hm-query-loop'
) }
>
<Spinner />
</Placeholder>
</div>
);
}

/**
* Defer rendering of off-screen Query Loop blocks in the editor.
*
* Until a Query Loop scrolls near the viewport it is replaced with a cheap
* placeholder that fires no API requests. Selecting the block (e.g. right
* after inserting it, or via the List View) renders it immediately, and once
* a block has been rendered it stays rendered so scrolling away does not
* discard edits or trigger a refetch.
*
* This HOC is registered last so it wraps the plugin's other Query Loop
* enhancements: while the placeholder is shown none of them — nor the core
* block itself — are mounted.
*/
const withViewportPlaceholder = createHigherOrderComponent( ( BlockEdit ) => {
return ( props ) => {
const { name, isSelected, clientId } = props;

if ( name !== 'core/query' ) {
return <BlockEdit { ...props } />;
}

const [ isReady, setIsReady ] = useState( false );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is technically going to throw a rules of hooks lint violation since the useState, etc happens after a conditional return. In practice it works, but abstracting everything after the early return as LazyBlockEdit and baking the initial crateHOC call down to,

createHigherOrderComponent( ( BlockEdit ) => {
  return ( props ) => {
    if ( props.name !== 'core/query' ) {
      return <BlockEdit { ...props } />;
    }

    return <LazyBlockEdit BlockEdit={ BlockEdit } { ...props } />;
  }

I believe that to be technically more recommended

const handleEnterViewport = useCallback( () => setIsReady( true ), [] );

// The block must also render when one of its inner blocks is selected
// (e.g. reached through the List View), otherwise that block would be
// selected but never mounted for editing.
const hasSelectedInnerBlock = useSelect(
( select ) =>
select( 'core/block-editor' ).hasSelectedInnerBlock(
clientId,
true
),
[ clientId ]
);

const isActive = isSelected || hasSelectedInnerBlock;

// Latch to rendered once selected so deselecting does not collapse the
// block back to a placeholder and refetch.
useEffect( () => {
if ( isActive ) {
setIsReady( true );
}
}, [ isActive ] );

if ( isReady || isActive ) {
return <BlockEdit { ...props } />;
}

return <QueryLoopPlaceholder onEnterViewport={ handleEnterViewport } />;
};
}, 'withViewportPlaceholder' );

addFilter(
'editor.BlockEdit',
'hm-query-loop/with-viewport-placeholder',
withViewportPlaceholder
);
12 changes: 12 additions & 0 deletions src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,15 @@
}
}
}

/**
* Placeholder shown for off-screen Query Loop blocks until they scroll into
* view. Reserves vertical space so the block is tall enough to be observed and
* so the surrounding layout does not jump when the real block is swapped in.
*/
.hm-query-loop-viewport-placeholder {

.components-placeholder {
min-height: 200px;
}
}
154 changes: 154 additions & 0 deletions tests/e2e/viewport-placeholder.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/* eslint-disable no-console */
/**
* Test that off-screen Query Loop blocks are replaced with a lightweight
* placeholder in the editor until they scroll into view.
*
* Mounting the real Query Loop block fires a REST request to fetch its posts
* for the preview. On a page with many query loops, rendering every one up
* front dispatches all of those requests on editor load. This plugin defers
* the off-screen blocks: they render a cheap placeholder (which issues no
* request) until they intersect the viewport, at which point the real block
* is mounted.
*/
const { test, expect } = require( './fixtures' );

const PLACEHOLDER_SELECTOR = '.hm-query-loop-viewport-placeholder';

// A single query loop followed by a tall spacer, repeated, so that the later
// query loops start well below the fold on a standard test viewport.
const queryLoopWithSpacer = (
queryId
) => `<!-- wp:query {"queryId":${ queryId },"query":{"perPage":3,"postType":"post","inherit":false}} -->
<div class="wp-block-query"><!-- wp:post-template -->
<!-- wp:post-title /-->
<!-- /wp:post-template --></div>
<!-- /wp:query -->

<!-- wp:spacer {"height":"1500px"} -->
<div style="height:1500px" aria-hidden="true" class="wp-block-spacer"></div>
<!-- /wp:spacer -->`;

test.describe( 'Viewport placeholder for Query Loop blocks', () => {
test( 'renders placeholders for off-screen query loops and loads them on scroll', async ( {
page,
admin,
editor,
} ) => {
await admin.createNewPost( { postType: 'page' } );
await page.waitForTimeout( 1500 );

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

// Three query loops separated by tall spacers. The first sits in the
// viewport on load; the later two start below the fold.
await editor.setContent(
[
queryLoopWithSpacer( 101 ),
queryLoopWithSpacer( 102 ),
queryLoopWithSpacer( 103 ),
].join( '\n\n' )
);

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

// All three query blocks should exist in the block tree.
const queryBlockCount = await page.evaluate(
() =>
window.wp.data
.select( 'core/block-editor' )
.getBlocksByName( 'core/query' ).length
);
expect( queryBlockCount ).toBe( 3 );

// Let the intersection observers settle for the initial viewport.
await page.waitForTimeout( 1500 );

// At least one of the below-the-fold query loops should be showing the
// placeholder rather than the fully rendered block.
const initialPlaceholders = await canvas
.locator( PLACEHOLDER_SELECTOR )
.count();
expect( initialPlaceholders ).toBeGreaterThan( 0 );

// The last query loop is the furthest down the page, so it should be a
// placeholder before we scroll to it.
const lastLoopPlaceholder = canvas
.locator( PLACEHOLDER_SELECTOR )
.last();
await expect( lastLoopPlaceholder ).toBeVisible();

// Scroll the placeholder into view; the observer should then swap in
// the real block, so the placeholder count drops.
await lastLoopPlaceholder.scrollIntoViewIfNeeded();
await page.waitForTimeout( 1500 );

const remainingPlaceholders = await canvas
.locator( PLACEHOLDER_SELECTOR )
.count();
expect( remainingPlaceholders ).toBeLessThan( initialPlaceholders );
} );

test( 'renders a selected query loop immediately instead of a placeholder', async ( {
page,
admin,
editor,
} ) => {
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();
}

await editor.setContent(
[
queryLoopWithSpacer( 201 ),
queryLoopWithSpacer( 202 ),
queryLoopWithSpacer( 203 ),
].join( '\n\n' )
);

await page.waitForTimeout( 1500 );

// Select the last query loop via the data store (as the List View
// would). A selected block must render immediately, even off-screen,
// so its inspector controls and content are available for editing.
await page.evaluate( () => {
const blocks = window.wp.data
.select( 'core/block-editor' )
.getBlocksByName( 'core/query' );
window.wp.data
.dispatch( 'core/block-editor' )
.selectBlock( blocks[ blocks.length - 1 ] );
} );

await page.waitForTimeout( 1000 );

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

// The selected (last) query loop should no longer be a placeholder.
// With three loops and the last one selected, at most the middle one
// can remain a placeholder.
const placeholders = await canvas
.locator( PLACEHOLDER_SELECTOR )
.count();
expect( placeholders ).toBeLessThan( 2 );
} );
} );
Loading