From 908d9f52b3737c3990df185c3f2359173d165c60 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 11:08:06 +0000 Subject: [PATCH 1/9] Add server-side rendered preview for unselected query loop blocks Pages with many query loops (e.g. magazine homepages) trigger a large number of API requests as each block loads its content previews. This replaces the full editor rendering with a server-side rendered preview when the query loop block is not selected, switching to the full editor when the user clicks on it. Registers a hidden hm-query-loop/preview block whose render_callback runs do_blocks() on the serialized block markup. The editor uses ServerSideRender with httpMethod="POST" to send the full inner blocks content to this endpoint, wrapped in the Disabled component to prevent interaction until the block is selected. https://claude.ai/code/session_01LSHcKsEAo3tV17kuSFHTaj --- hm-query-loop.php | 45 ++++++++++++++++++++++++ src/index.js | 88 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/hm-query-loop.php b/hm-query-loop.php index 372b881..e50609f 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -46,6 +46,9 @@ function init() { // Add contexts to query and post-template block. add_filter( 'block_type_metadata', __NAMESPACE__ . '\\filter_block_metadata' ); + + // Register the preview block for server-side rendering in the editor. + register_preview_block(); } add_action( 'init', __NAMESPACE__ . '\\init', 9 ); @@ -507,3 +510,45 @@ function track_displayed_posts( $posts, $query ) { return $posts; } + +/** + * Register the preview block used for server-side rendering query loop + * previews in the editor. This block is not insertable by users. + */ +function register_preview_block() { + register_block_type( + 'hm-query-loop/preview', + [ + 'api_version' => 3, + 'attributes' => [ + 'content' => [ + 'type' => 'string', + 'default' => '', + ], + ], + 'supports' => [ + 'inserter' => false, + ], + 'render_callback' => __NAMESPACE__ . '\\render_preview_block', + ] + ); +} + +/** + * Render callback for the preview block. + * + * Processes the serialized block content through do_blocks() to produce + * the same output as a frontend page render. + * + * @param array $attributes Block attributes containing 'content'. + * @return string Rendered HTML. + */ +function render_preview_block( $attributes ) { + $content = $attributes['content'] ?? ''; + + if ( empty( $content ) ) { + return ''; + } + + return do_blocks( $content ); +} diff --git a/src/index.js b/src/index.js index 52448ad..e0003f1 100644 --- a/src/index.js +++ b/src/index.js @@ -5,9 +5,21 @@ import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { InspectorControls } from '@wordpress/block-editor'; -import { PanelBody, ToggleControl, TextControl } from '@wordpress/components'; +import { + Disabled, + PanelBody, + ToggleControl, + TextControl, +} from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { createContext, useContext, useEffect } from '@wordpress/element'; +import { + createContext, + useContext, + useEffect, + useMemo, +} from '@wordpress/element'; +import ServerSideRender from '@wordpress/server-side-render'; +import { serialize } from '@wordpress/blocks'; /** * Styles @@ -493,3 +505,75 @@ addFilter( 'hm-query-loop/with-post-template-styles', withPostTemplateStyles ); + +/** + * Replace the Query Loop block content with a server-side rendered preview + * when the block is not selected. This avoids the many API requests that + * inner blocks make to load content, which is costly on pages with lots of + * query loops. When the user clicks on the block it switches to the full + * editor. + */ +const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + const { name, clientId } = props; + + if ( name !== 'core/query' ) { + return ; + } + + const isSelected = useSelect( + ( select ) => { + const { isBlockSelected, hasSelectedInnerBlock } = + select( 'core/block-editor' ); + return ( + isBlockSelected( clientId ) || + hasSelectedInnerBlock( clientId, true ) + ); + }, + [ clientId ] + ); + + const block = useSelect( + ( select ) => { + return select( 'core/block-editor' ).getBlock( clientId ); + }, + [ clientId ] + ); + + const postId = useSelect( ( select ) => { + try { + return select( 'core/editor' )?.getCurrentPostId?.() || 0; + } catch { + return 0; + } + }, [] ); + + const serializedContent = useMemo( () => { + if ( ! block ) { + return ''; + } + return serialize( [ block ] ); + }, [ block ] ); + + if ( isSelected ) { + return ; + } + + return ( + + + + ); + }; +}, 'withSSRPreview' ); + +addFilter( + 'editor.BlockEdit', + 'hm-query-loop/with-ssr-preview', + withSSRPreview +); From db4757d9ae5295652ee2c799e34a036efc53db84 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 11:37:05 +0000 Subject: [PATCH 2/9] Register preview block client-side and remove Disabled wrapper The ServerSideRender component uses __experimentalSanitizeBlockAttributes internally which requires the block to be registered in the JS block registry. Add a minimal registerBlockType call for hm-query-loop/preview with inserter: false so it resolves during attribute sanitization. Remove the Disabled wrapper since ServerSideRender already renders static HTML that doesn't need interaction prevention. https://claude.ai/code/session_01LSHcKsEAo3tV17kuSFHTaj --- src/index.js | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/index.js b/src/index.js index e0003f1..4d54d45 100644 --- a/src/index.js +++ b/src/index.js @@ -5,12 +5,7 @@ import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { InspectorControls } from '@wordpress/block-editor'; -import { - Disabled, - PanelBody, - ToggleControl, - TextControl, -} from '@wordpress/components'; +import { PanelBody, ToggleControl, TextControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { createContext, @@ -19,13 +14,35 @@ import { useMemo, } from '@wordpress/element'; import ServerSideRender from '@wordpress/server-side-render'; -import { serialize } from '@wordpress/blocks'; +import { registerBlockType, serialize } from '@wordpress/blocks'; /** * Styles */ import './index.scss'; +/** + * Register the preview block used for server-side rendering query loop + * previews. This block is not insertable and has no editor UI; it exists + * solely so the ServerSideRender component can resolve the block type + * when sanitizing attributes before making the API request. + */ +registerBlockType( 'hm-query-loop/preview', { + title: 'Query Loop Preview', + category: 'widgets', + supports: { + inserter: false, + }, + attributes: { + content: { + type: 'string', + default: '', + }, + }, + edit: () => null, + save: () => null, +} ); + /** * Create a context for tracking used post IDs within a query loop in the editor. */ @@ -560,14 +577,12 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { } return ( - - - + ); }; }, 'withSSRPreview' ); From 69ec283476d432a6f5d27ffe42330a31b9f98430 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 13:00:43 +0000 Subject: [PATCH 3/9] Add PR playground preview and cleanup workflows Adds two GitHub Actions workflows adapted from hm-url-tabs: - pr-playground-preview.yml: On PR open/sync, builds the plugin, pushes built assets to a pr-N-built branch, and appends a WordPress Playground preview button to the PR description using the official action. - pr-cleanup.yml: Deletes the pr-N-built branch when the PR is closed. https://claude.ai/code/session_01LSHcKsEAo3tV17kuSFHTaj --- .github/workflows/pr-cleanup.yml | 27 ++++++ .github/workflows/pr-playground-preview.yml | 101 ++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 .github/workflows/pr-cleanup.yml create mode 100644 .github/workflows/pr-playground-preview.yml diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml new file mode 100644 index 0000000..d0d6a04 --- /dev/null +++ b/.github/workflows/pr-cleanup.yml @@ -0,0 +1,27 @@ +name: PR Cleanup + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Delete PR preview branch + run: | + BRANCH_NAME="pr-${{ github.event.pull_request.number }}-built" + + echo "Checking if branch $BRANCH_NAME exists..." + + # Check if the branch exists on remote + if git ls-remote --heads "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then + echo "Branch $BRANCH_NAME found. Deleting..." + git push "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" --delete "$BRANCH_NAME" + echo "Successfully deleted branch $BRANCH_NAME" + else + echo "Branch $BRANCH_NAME does not exist. Nothing to clean up." + fi diff --git a/.github/workflows/pr-playground-preview.yml b/.github/workflows/pr-playground-preview.yml new file mode 100644 index 0000000..1754532 --- /dev/null +++ b/.github/workflows/pr-playground-preview.yml @@ -0,0 +1,101 @@ +name: PR Playground Preview + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: write + pull-requests: write + +jobs: + build-and-preview: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies + run: npm i + + - name: Replace version with PR info + run: | + # Create PR version string + PR_VERSION="Pull Request #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }}" + + # Replace __VERSION__ in plugin file + sed -i "s/__VERSION__/$PR_VERSION/g" hm-query-loop.php + + echo "Updated plugin version to: $PR_VERSION" + cat hm-query-loop.php | grep "Version:" + + - name: Build plugin + run: npm run build + + - name: Push built files to PR branch + run: | + # Configure git + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + # Create and checkout a new branch for this PR's built files + BRANCH_NAME="pr-${{ github.event.pull_request.number }}-built" + + # Create/recreate the branch from current state + git checkout -B "$BRANCH_NAME" + + # Add all files including built assets and any lock file changes + git add -f build/ + git add -A + + # Commit if there are changes + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Build assets for PR #${{ github.event.pull_request.number }} (commit: ${{ github.event.pull_request.head.sha }})" + git push origin "$BRANCH_NAME" --force + fi + + - name: Generate Playground blueprint and post preview button + uses: WordPress/action-wp-playground-pr-preview@v2 + with: + mode: 'append-to-description' + blueprint: | + { + "landingPage": "/wp-admin/", + "preferredVersions": { + "php": "8.3", + "wp": "6.8" + }, + "steps": [ + { + "step": "login", + "username": "admin", + "password": "password" + }, + { + "step": "installTheme", + "themeData": { + "resource": "wordpress.org/themes", + "slug": "twentytwentyfive" + }, + "options": { + "activate": true + } + }, + { + "step": "installPlugin", + "pluginZipFile": { + "resource": "url", + "url": "https://github.com/${{ github.repository }}/archive/refs/heads/pr-${{ github.event.pull_request.number }}-built.zip" + } + } + ] + } + github-token: ${{ secrets.GITHUB_TOKEN }} From 8fbd39fd2d9f237dcd07f02e3c99d05818981864 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 13:05:56 +0000 Subject: [PATCH 4/9] Only send post_id to block renderer when it is a valid integer In the site editor, getCurrentPostId() can return a non-numeric template identifier. Sending post_id=0 or a string also fails the block-renderer endpoint's integer validation. Only include the parameter when we have a real numeric post ID. https://claude.ai/code/session_01LSHcKsEAo3tV17kuSFHTaj --- src/index.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 4d54d45..40e245c 100644 --- a/src/index.js +++ b/src/index.js @@ -559,7 +559,8 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { const postId = useSelect( ( select ) => { try { - return select( 'core/editor' )?.getCurrentPostId?.() || 0; + const id = select( 'core/editor' )?.getCurrentPostId?.(); + return Number.isInteger( id ) && id > 0 ? id : 0; } catch { return 0; } @@ -576,12 +577,14 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { return ; } + const urlQueryArgs = postId ? { post_id: postId } : {}; + return ( ); }; From 887303da9d9177439c40750394b3fb2768648cf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 14:20:11 +0000 Subject: [PATCH 5/9] Wrap SSR preview in Disabled to prevent link navigation The server-rendered HTML contains real anchor tags and other interactive elements that would navigate the editor iframe when clicked. Wrapping in the Disabled component prevents this. https://claude.ai/code/session_01LSHcKsEAo3tV17kuSFHTaj --- src/index.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/index.js b/src/index.js index 40e245c..c352530 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,12 @@ import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { InspectorControls } from '@wordpress/block-editor'; -import { PanelBody, ToggleControl, TextControl } from '@wordpress/components'; +import { + Disabled, + PanelBody, + ToggleControl, + TextControl, +} from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { createContext, @@ -580,12 +585,14 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { const urlQueryArgs = postId ? { post_id: postId } : {}; return ( - + + + ); }; }, 'withSSRPreview' ); From 31436e92061c7c73bd153ef0d8ed7aee9518400d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 16:29:46 +0000 Subject: [PATCH 6/9] Use inert attribute instead of Disabled, keep blocks live once activated Two problems with the Disabled component wrapper: - It changes the DOM structure so the query loop block can't be selected via click (the Disabled wrapper intercepts the event) - Reverting to SSR preview on deselection triggers unnecessary re-fetches Fix both by: - Adding the inert attribute to top-level elements in the rendered HTML server-side using WP_HTML_Tag_Processor, preserving the original DOM structure so block selection works naturally - Using useState to latch blocks as "activated" once first selected, keeping them in full edit mode permanently for the session https://claude.ai/code/session_01LSHcKsEAo3tV17kuSFHTaj --- hm-query-loop.php | 16 +++++++++++++-- src/index.js | 50 ++++++++++++++++++++++++++++------------------- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/hm-query-loop.php b/hm-query-loop.php index e50609f..978e833 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -538,7 +538,9 @@ function register_preview_block() { * Render callback for the preview block. * * Processes the serialized block content through do_blocks() to produce - * the same output as a frontend page render. + * the same output as a frontend page render. Adds the inert attribute to + * all top-level child elements so that links and interactive elements + * cannot be clicked in the editor preview. * * @param array $attributes Block attributes containing 'content'. * @return string Rendered HTML. @@ -550,5 +552,15 @@ function render_preview_block( $attributes ) { return ''; } - return do_blocks( $content ); + $html = do_blocks( $content ); + + $processor = new \WP_HTML_Tag_Processor( $html ); + while ( $processor->next_tag() ) { + // Only process top-level elements (depth 0). + if ( $processor->get_current_depth() === 0 ) { + $processor->set_attribute( 'inert', true ); + } + } + + return $processor->get_updated_html(); } diff --git a/src/index.js b/src/index.js index c352530..e0781a2 100644 --- a/src/index.js +++ b/src/index.js @@ -5,18 +5,14 @@ import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { InspectorControls } from '@wordpress/block-editor'; -import { - Disabled, - PanelBody, - ToggleControl, - TextControl, -} from '@wordpress/components'; +import { PanelBody, ToggleControl, TextControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { createContext, useContext, useEffect, useMemo, + useState, } from '@wordpress/element'; import ServerSideRender from '@wordpress/server-side-render'; import { registerBlockType, serialize } from '@wordpress/blocks'; @@ -530,10 +526,14 @@ addFilter( /** * Replace the Query Loop block content with a server-side rendered preview - * when the block is not selected. This avoids the many API requests that - * inner blocks make to load content, which is costly on pages with lots of - * query loops. When the user clicks on the block it switches to the full - * editor. + * when the block has not yet been selected. This avoids the many API requests + * that inner blocks make to load content, which is costly on pages with lots + * of query loops. Once the user clicks on the block it switches to the full + * editor and stays live from that point on. + * + * The rendered HTML has the inert attribute on its top-level elements + * (added server-side) to prevent link navigation without wrapping in a + * Disabled component that changes the DOM structure. */ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { return ( props ) => { @@ -543,6 +543,8 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { return ; } + const [ isActivated, setIsActivated ] = useState( false ); + const isSelected = useSelect( ( select ) => { const { isBlockSelected, hasSelectedInnerBlock } = @@ -555,11 +557,21 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { [ clientId ] ); + // Once selected, stay in edit mode permanently. + useEffect( () => { + if ( isSelected && ! isActivated ) { + setIsActivated( true ); + } + }, [ isSelected, isActivated ] ); + const block = useSelect( ( select ) => { + if ( isActivated ) { + return null; + } return select( 'core/block-editor' ).getBlock( clientId ); }, - [ clientId ] + [ clientId, isActivated ] ); const postId = useSelect( ( select ) => { @@ -578,21 +590,19 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { return serialize( [ block ] ); }, [ block ] ); - if ( isSelected ) { + if ( isActivated ) { return ; } const urlQueryArgs = postId ? { post_id: postId } : {}; return ( - - - + ); }; }, 'withSSRPreview' ); From c70372ec31d8a744986363b8dfb8e03b3669ffd7 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Fri, 15 May 2026 11:45:27 +0100 Subject: [PATCH 7/9] Replace auto-activate SSR preview with explicit Preview/Edit toolbar toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach auto-activated on first click and never reverted, which caused a refetch storm: getBlock(clientId) is reference-unstable so every editor keystroke triggered a new POST to the block renderer. The new approach adds an explicit Preview/Edit toggle to the Query Loop toolbar. Snapshot serialization happens once on entering preview mode (gated in a useEffect keyed only on mode transitions), so unrelated editor activity never fires additional renderer requests. A per-session hm-query-loop/preview-mode Redux store holds per-clientId mode state — intentionally not persisted to post content, matching the collapsed-panel convention. Co-Authored-By: Claude Opus 4.7 --- src/index.js | 148 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 97 insertions(+), 51 deletions(-) diff --git a/src/index.js b/src/index.js index 7707f9f..77deec5 100644 --- a/src/index.js +++ b/src/index.js @@ -3,21 +3,29 @@ */ import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; -import { useSelect } from '@wordpress/data'; -import { InspectorControls } from '@wordpress/block-editor'; +import { + useSelect, + useDispatch, + select as syncSelect, + register, + createReduxStore, +} from '@wordpress/data'; +import { InspectorControls, BlockControls } from '@wordpress/block-editor'; import { PanelBody, ToggleControl, TextControl, SelectControl, + ToolbarGroup, + ToolbarButton, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { createContext, useContext, useEffect, - useMemo, useState, + useCallback, } from '@wordpress/element'; import ServerSideRender from '@wordpress/server-side-render'; import { registerBlockType, serialize } from '@wordpress/blocks'; @@ -49,6 +57,36 @@ registerBlockType( 'hm-query-loop/preview', { save: () => null, } ); +/** + * Per-clientId Preview/Edit mode store. + * Mode resets on page reload (intentional — like collapsed-panel state). + */ +const PREVIEW_MODE_STORE = 'hm-query-loop/preview-mode'; + +register( + createReduxStore( PREVIEW_MODE_STORE, { + reducer( state = { modes: {} }, action ) { + if ( action.type === 'SET_MODE' ) { + return { + modes: { ...state.modes, [ action.clientId ]: action.mode }, + }; + } + return state; + }, + actions: { + setMode: ( clientId, mode ) => ( { + type: 'SET_MODE', + clientId, + mode, + } ), + }, + selectors: { + getMode: ( state, clientId ) => + state.modes[ clientId ] || 'edit', + }, + } ) +); + /** * Create a context for tracking used post IDs within a query loop in the editor. */ @@ -562,15 +600,16 @@ addFilter( ); /** - * Replace the Query Loop block content with a server-side rendered preview - * when the block has not yet been selected. This avoids the many API requests - * that inner blocks make to load content, which is costly on pages with lots - * of query loops. Once the user clicks on the block it switches to the full - * editor and stays live from that point on. + * Add a Preview/Edit toolbar toggle to each Query Loop block. + * + * In Preview mode, the block renders a server-side HTML snapshot. The snapshot + * is taken once on entry into preview mode (keyed on the mode transition), so + * typing elsewhere in the editor never triggers additional renderer requests. + * + * In Edit mode the standard live editor is shown with all settings accessible. * * The rendered HTML has the inert attribute on its top-level elements - * (added server-side) to prevent link navigation without wrapping in a - * Disabled component that changes the DOM structure. + * (added server-side) to prevent link navigation. */ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { return ( props ) => { @@ -580,36 +619,12 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { return ; } - const [ isActivated, setIsActivated ] = useState( false ); - - const isSelected = useSelect( - ( select ) => { - const { isBlockSelected, hasSelectedInnerBlock } = - select( 'core/block-editor' ); - return ( - isBlockSelected( clientId ) || - hasSelectedInnerBlock( clientId, true ) - ); - }, + const mode = useSelect( + ( select ) => select( PREVIEW_MODE_STORE ).getMode( clientId ), [ clientId ] ); - // Once selected, stay in edit mode permanently. - useEffect( () => { - if ( isSelected && ! isActivated ) { - setIsActivated( true ); - } - }, [ isSelected, isActivated ] ); - - const block = useSelect( - ( select ) => { - if ( isActivated ) { - return null; - } - return select( 'core/block-editor' ).getBlock( clientId ); - }, - [ clientId, isActivated ] - ); + const { setMode } = useDispatch( PREVIEW_MODE_STORE ); const postId = useSelect( ( select ) => { try { @@ -620,26 +635,57 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { } }, [] ); - const serializedContent = useMemo( () => { - if ( ! block ) { - return ''; + // Stable snapshot of serialized block content for the SSR component. + // Only re-taken when transitioning into preview mode — never on arbitrary + // store changes — so there is no refetch storm while the user types elsewhere. + const [ snapshot, setSnapshot ] = useState( '' ); + + useEffect( () => { + if ( mode === 'preview' ) { + const block = + syncSelect( 'core/block-editor' ).getBlock( clientId ); + if ( block ) { + setSnapshot( serialize( [ block ] ) ); + } } - return serialize( [ block ] ); - }, [ block ] ); + }, [ mode, clientId ] ); - if ( isActivated ) { - return ; - } + const toggleMode = useCallback( () => { + setMode( clientId, mode === 'edit' ? 'preview' : 'edit' ); + }, [ mode, clientId, setMode ] ); + const isPreview = mode === 'preview'; const urlQueryArgs = postId ? { post_id: postId } : {}; return ( - + <> + + + + + + { isPreview ? ( + + ) : ( + + ) } + ); }; }, 'withSSRPreview' ); From 796b6915f094ad4805d46959aef4e6dfd982bad6 Mon Sep 17 00:00:00 2001 From: Matthew Haines-Young Date: Fri, 3 Jul 2026 13:04:47 +0100 Subject: [PATCH 8/9] Fix render_preview_block: use WP_HTML_Processor, not WP_HTML_Tag_Processor get_current_depth() only exists on WP_HTML_Processor. Calling it on WP_HTML_Tag_Processor caused a PHP fatal, which produced an empty REST API response and a blank preview. create_fragment() wraps content in an implicit body context, so top-level elements are at depth 1 rather than 0. Co-Authored-By: Claude Sonnet 4.6 --- hm-query-loop.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hm-query-loop.php b/hm-query-loop.php index 2e9f18f..caf5c4a 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -560,10 +560,11 @@ function render_preview_block( $attributes ) { $html = do_blocks( $content ); - $processor = new \WP_HTML_Tag_Processor( $html ); + // WP_HTML_Processor::create_fragment() wraps the HTML in an implicit + // body context, so top-level elements are at depth 1, not 0. + $processor = \WP_HTML_Processor::create_fragment( $html ); while ( $processor->next_tag() ) { - // Only process top-level elements (depth 0). - if ( $processor->get_current_depth() === 0 ) { + if ( $processor->get_current_depth() === 1 ) { $processor->set_attribute( 'inert', true ); } } From 0ee6e7cff48c908413b92aaa056b8fcc1a29d14a Mon Sep 17 00:00:00 2001 From: Matthew Haines-Young Date: Mon, 6 Jul 2026 22:12:26 +0100 Subject: [PATCH 9/9] Fix SSR preview: capture snapshot in toggle handler, not useEffect Moving snapshot capture from a useEffect into the toggleMode handler ensures React batches setSnapshot and setMode into a single render. The first ServerSideRender POST then has real content rather than an empty string, eliminating the spurious empty initial render. Also removes leftover echo/die() debug code and fixes a blank line. --- hm-query-loop.php | 1 + src/index.js | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/hm-query-loop.php b/hm-query-loop.php index e22c842..91a6dfe 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -582,6 +582,7 @@ function render_preview_block( $attributes ) { // WP_HTML_Processor::create_fragment() wraps the HTML in an implicit // body context, so top-level elements are at depth 1, not 0. $processor = \WP_HTML_Processor::create_fragment( $html ); + while ( $processor->next_tag() ) { if ( $processor->get_current_depth() === 1 ) { $processor->set_attribute( 'inert', true ); diff --git a/src/index.js b/src/index.js index acbd5f5..9a65129 100644 --- a/src/index.js +++ b/src/index.js @@ -658,22 +658,22 @@ const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => { }, [] ); // Stable snapshot of serialized block content for the SSR component. - // Only re-taken when transitioning into preview mode — never on arbitrary - // store changes — so there is no refetch storm while the user types elsewhere. + // Captured in the toggle handler (not a useEffect) so that the first + // render in preview mode already has content — avoiding an empty + // initial POST to the block renderer endpoint. const [ snapshot, setSnapshot ] = useState( '' ); - useEffect( () => { - if ( mode === 'preview' ) { + const toggleMode = useCallback( () => { + if ( mode === 'edit' ) { const block = syncSelect( 'core/block-editor' ).getBlock( clientId ); if ( block ) { setSnapshot( serialize( [ block ] ) ); } + setMode( clientId, 'preview' ); + } else { + setMode( clientId, 'edit' ); } - }, [ mode, clientId ] ); - - const toggleMode = useCallback( () => { - setMode( clientId, mode === 'edit' ? 'preview' : 'edit' ); }, [ mode, clientId, setMode ] ); const isPreview = mode === 'preview';