Skip to content

Defer off-screen Query Loop blocks to a viewport placeholder#27

Merged
roborourke merged 1 commit into
mainfrom
claude/query-loop-viewport-placeholder-dh2yld
Jul 24, 2026
Merged

Defer off-screen Query Loop blocks to a viewport placeholder#27
roborourke merged 1 commit into
mainfrom
claude/query-loop-viewport-placeholder-dh2yld

Conversation

@roborourke

Copy link
Copy Markdown
Collaborator

Summary

An alternative approach to making pages with many Query Loop blocks more performant in the editor.

Rendering a Query Loop's edit component mounts the core block, which immediately fires a REST request to fetch its posts for the preview. On a page with lots of query loops, every one of those requests is dispatched up front on editor load — slow, and it can hammer the server.

This adds a withViewportPlaceholder HOC that replaces an off-screen Query Loop with a cheap placeholder that issues no requests. The real block is only mounted once it scrolls near the viewport, spreading the API requests out as the user scrolls rather than firing them all at load.

How it works

  • withViewportPlaceholder wraps core/query's editor.BlockEdit. It is registered last, so it is the outermost HOC — while the placeholder is shown, none of the plugin's other core/query enhancements (nor the core block itself) are mounted.
  • QueryLoopPlaceholder renders a <Placeholder> + <Spinner> and attaches an IntersectionObserver. The observer is constructed from the target node's own ownerDocument.defaultView, so it uses the correct viewport whether or not the editor canvas is iframed (site editor / block-themed post editor). A 300px rootMargin preloads a block just before it comes on screen. If IntersectionObserver is unavailable, the block renders immediately as a safe fallback.
  • Immediate render on interaction: selecting the block (e.g. right after inserting it) or selecting one of its inner blocks via the List View renders it immediately, so it never gets stuck as an unmountable placeholder.
  • Latching: once a block has been rendered it stays rendered, so scrolling away does not discard edits or trigger a refetch.

Changes

  • src/index.js — new QueryLoopPlaceholder component and withViewportPlaceholder HOC + filter registration.
  • src/index.scss — placeholder styling (reserves min-height so the block is observable and the layout doesn't jump on swap-in).
  • tests/e2e/viewport-placeholder.spec.js — e2e coverage: off-screen loops render placeholders and load on scroll; a selected loop renders immediately.
  • CLAUDE.md — documents the new lazy-rendering behavior.

Testing

  • npm run build — compiles cleanly.
  • npm run lint:js / npm run lint:css — clean.
  • E2E spec added but not run in this environment (no Docker/wp-env available here).

🤖 Generated with Claude Code


Generated by Claude Code

Rendering a Query Loop's edit component mounts the core block, which
immediately fires a REST request to fetch posts for the preview. On a
page with many query loops, every one of those requests is dispatched up
front on editor load.

Add a withViewportPlaceholder HOC (registered last, so it wraps the
plugin's other core/query enhancements) that replaces an off-screen
Query Loop with a cheap placeholder issuing no requests. An
IntersectionObserver — built from the target node's own defaultView so
it works whether or not the canvas is iframed — swaps in the real block
once it nears the viewport (300px rootMargin preload). Selecting the
block or one of its inner blocks renders it immediately, and once
rendered a block stays rendered so scrolling away neither discards edits
nor refetches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAvqR2G3Q3aJkTtePzmLg7
@github-actions

Copy link
Copy Markdown

Playwright test results

passed  22 passed

Details

stats  22 tests across 7 suites
duration  1 minute, 11 seconds
commit  0f34880

@roborourke
roborourke marked this pull request as ready for review July 24, 2026 13:50
@joehoyle

Copy link
Copy Markdown
Member

I have a few thoughts:

  1. Is there precedent for this in Core already on other blocks?
  2. Is the performance issue causing bad UX? If so, I'm wondering could you just load all 2-3 seconds after init. I can see the UX of scrolling and things loading in being quite poor too, so not sure this would overall make the UX better?
  3. Would it be any better to batch all the fetches into a single REST API request, or is that not where the performance bottle-neck lies?
  4. How slow really is it right now?

@roborourke

roborourke commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Cheers @joehoyle

  1. No there's no precedent I'm aware of for this
  2. The UX is definitely worse, to the extent a client is complaining about it - it slows the editorial team down too much because the things they want to edit might not be interactive for upwards of 15 seconds. For the FSE homepage I'm looking at it's the difference between 189 requests all happening at roughly the same time versus 34 up front with this change
  3. That might be possible with apiFetch middleware I suppose, but the query loop block itself and the blocks inside the post template like featured image, tags etc... all make their own requests so not sure how feasible batching would be
  4. Can be 20+ seconds for full page load and for the editor to be usable

@joehoyle

Copy link
Copy Markdown
Member

That might be possible with apiFetch middleware I suppose, but the query loop block itself and the blocks inside the post template like featured image, tags etc... all make their own requests so not sure how feasible batching would be

Interesting-- this makes me wonder to what degree is it the query loop block its self, versus what is inside it-- and to that end, is the real performance win here that you're effectively just adding lazy loading to the block editor!

I can see this making sense-- I guess if someone really wants to have no popping on scrolling, they just need to scroll to the bottom to load everything.

@roborourke

Copy link
Copy Markdown
Collaborator Author

Yeah. Things will pop up as they load on the initial view in core anyway so it isn't too different compared to that UX. It would be good if there was some server side hydration for blocks like the query loop but gutenberg just isn't built that way.

@roborourke
roborourke merged commit 1ff4885 into main Jul 24, 2026
1 check passed
@roborourke
roborourke deleted the claude/query-loop-viewport-placeholder-dh2yld branch July 24, 2026 14:43

@kadamwhite kadamwhite left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@roborourke I know this has merged, but two suggestions

Comment thread src/index.js
Comment on lines +644 to +655
* 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.

Copy link
Copy Markdown

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.

Comment thread src/index.js
return <BlockEdit { ...props } />;
}

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

Copy link
Copy Markdown

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants