Repository navigation
Defer off-screen Query Loop blocks to a viewport placeholder #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 || []; | ||
|
|
@@ -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. | ||
| * | ||
| * @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 ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
| ); | ||
| 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 ); | ||
| } ); | ||
| } ); |
There was a problem hiding this comment.
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.
To me this communicates the same information more directly.