Skip to content

Blocks: lazily register pure display blocks on first render (JETPACK-1747)#49840

Open
kraftbj wants to merge 13 commits into
trunkfrom
lazy-block-registration
Open

Blocks: lazily register pure display blocks on first render (JETPACK-1747)#49840
kraftbj wants to merge 13 commits into
trunkfrom
lazy-block-registration

Conversation

@kraftbj

@kraftbj kraftbj commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Proposed changes

Part of the JETPACK-1747 per-request PHP/opcache reduction work. This is the lazy block registration slice (sibling to PR 49807, which deferred block render code).

When the Blocks module is active (which the AI Assistant requires, so most connected sites), Jetpack_Gutenberg::load_independent_blocks() runs at module-load time (after_setup_theme) and include_onces every block's registration PHP — ~46 files — on every request, including front-end pages that contain no Jetpack blocks. Each block then calls register_block_type() on init.

This PR defers the registration PHP of 21 verified "pure" display blocks on plain front-end requests. Each is registered just-in-time the first time the block (or a block whose subtree contains it) is encountered while rendering. Block-editor contexts (admin, REST, cron, WP-CLI, XML-RPC) keep loading every block eagerly, so the editor, the /wp/v2/block-types endpoint, and server-side rendering are unchanged.

How it works

  • is_block_editor_context() — early front-end-vs-editor detection. Returns true for admin/REST/cron/CLI/XML-RPC (and any non-web context with no REQUEST_URI), false only for plain front-end web requests. REST is detected from the request URL (anchored home-path + REST prefix, both the /wp-json/ and index-permalink /index.php/wp-json/ forms, plus the rest_route query var) because this runs before core defines REST_REQUEST.
  • On front-end requests, load_independent_blocks() skips the files in $lazy_blocks and registers one pre_render_block filter.
  • lazy_register_deferred_block() runs only for top-level blocks ($parent_block === null) and walks the full parsed subtree (register_deferred_blocks_in_subtree), registering every deferred Jetpack block it contains. This must happen at the top level: core resolves an inner block's block_type when it constructs that inner WP_Block, which is before the inner block's own pre_render_block fires — so registering a deferred dynamic block only at its own inner filter would be too late and its render_callback would be skipped. Synced patterns / reusable blocks (core/block) keep their content in a separate wp_block post that core only parses at render time, so the walk resolves the ref and recurses (with a cycle guard).
  • load_and_register_deferred_block() include_onces the block file and runs the init callback the include adds (captured by diffing $wp_filter['init']->callbacks), since init has already fired by render time.

Safety — what is NOT deferred

A block is only deferred if its init callback does nothing but Blocks::jetpack_register_block() (plus trivial guards), it registers exactly one block type named jetpack/<dir>, and it has no out-of-band consumer. Excluded blocks keep loading eagerly. Notable exclusions (and why), all encoded in the $lazy_blocks docblock:

  • Front-end side-effect hooks on init (must run regardless of whether the block is on the page): subscriptions (the_content paywall, comment gating), premium-content (template_redirect), sharing-button (hooked_block_types), cookie-consent (wp_footer), map (wp), donations, mailchimp, contact-form, contact-info (registers child blocks), recurring-payments, subscriber-login, instagram-gallery, etc.
  • videopress — registers jetpack/videopress-block (name ≠ directory), so a dirname-keyed lazy handler would never match it.
  • calendly, opentable, send-a-message — use plan_check; their availability is computed from the init-time jetpack_register_gutenberg_extensions hook, which lazy registration bypasses.
  • slideshow — its block file is required after init by modules/shortcodes/slideshow.php, which would make the lazy include_once a no-op and skip registration.
  • buttonsubscriptions/memberships call Button\render_email() (defined in button.php) out-of-band for WooCommerce e-mail rendering.
  • podcast-player, tiled-gallery — register a render_email_callback read off the block type by the WooCommerce e-mail editor, an out-of-band renderer that does not go through pre_render_block.

New blocks default to eager (opt-in list), so there is no regression risk for future blocks.

Measured impact (static)

On a connected, Blocks-only front page that contains no Jetpack blocks, the following stop loading:

  • 21 block registration files (extensions/blocks/*/*.php)
  • 2 top-level helper libs pulled in by those files (class-jetpack-top-posts-helper.php, class-jetpack-blog-stats-helper.php)

= 23 files / ~82 KB of source removed from the no-blocks front page. A front page that does contain a Jetpack block, and the block editor, load and render exactly as before.

Note: these numbers are a precise static accounting of the deferred file set. The standard get_included_files() loopback harness was not run here because the local Docker dev env is bound to a different (parallel) workspace and jp supports only one env at a time; the harness mu-plugin is in the testing instructions so the loopback can be confirmed.

Review

Ran 5 rounds of /ce-code-review + Codex adversarial review; both converged with no remaining correctness findings. Fixes made during review: inner-block timing (top-level subtree walk), synced-pattern (core/block) ref resolution, index-permalink REST detection, and the unsafe-block exclusions above (the deferred set shrank from 29 → 21 as couplings were found and verified).

Related product discussion/links

Does this pull request change what data or activity we track or use?

No.

Testing instructions

  1. jp build js-packages/social-logos && jp build plugins/jetpack, activate Jetpack with the Blocks module on (offline mode is fine).
  2. No-blocks front page: view a post/page with no Jetpack blocks. With this mu-plugin installed, confirm the 21 block files + 2 helper libs are absent from get_included_files() vs trunk:
    <?php
    /* Plugin Name: JETPACK-1747 footprint */
    add_action( 'shutdown', function () {
        if ( is_admin() ) { return; }
        $files = array_filter( get_included_files(), fn( $f ) => strpos( $f, '/jetpack-monorepo/projects/' ) !== false );
        $blocks = array_filter( $files, fn( $f ) => strpos( $f, '/extensions/blocks/' ) !== false );
        error_log( sprintf( 'JP1747 files=%d block_files=%d bytes=%d', count( $files ), count( $blocks ), array_sum( array_map( 'filesize', $files ) ) ) );
    }, PHP_INT_MAX );
  3. Block-containing front page: add e.g. a Top Posts, Business Hours, Eventbrite, Story, or Image Compare block to a post and view it — it must render exactly as on trunk (its file now loads lazily, via pre_render_block). Also verify a deferred block placed inside a synced pattern renders correctly (exercises the core/block ref resolution).
  4. Editor: open /wp-admin/post-new.php — all Jetpack blocks must still appear in the inserter (block-editor context loads eagerly).
  5. Run the unit tests: Jetpack_Gutenberg_Test (gate detection, subtree walk, synced-pattern resolution, inner-invocation skip).

Other information

  • Changelog entry added (plugins/jetpack, type other).
  • WP floor is 6.9, so wp_register_block_metadata_collection() and the APIs used here are always available; no capability guard needed.

When the Blocks module is active, Jetpack includes ~46 block registration
PHP files on every request via load_independent_blocks(), even on front-end
pages that contain no Jetpack blocks.

Defer the registration PHP of 29 verified "pure" display blocks (whose init
callback only calls Blocks::jetpack_register_block) on plain front-end
requests, registering each just-in-time the first time it is encountered
while rendering, via pre_render_block. Admin/REST/cron/CLI/XML-RPC
(block-editor) requests keep loading every block eagerly, so the editor, the
block-types REST endpoint and server-side rendering are unchanged.

Blocks with front-end side effects (paywall, sharing, cookie consent, etc.)
are intentionally excluded and keep loading eagerly.

See JETPACK-1747.
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the lazy-block-registration branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack lazy-block-registration

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions github-actions Bot added [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Status] In Progress [Tests] Includes Tests labels Jun 23, 2026
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!


Jetpack plugin:

The Jetpack plugin has different release cadences depending on the platform:

  • WordPress.com Simple releases happen as soon as you deploy your changes after merging this PR (PCYsg-Jjm-p2).
  • WoA releases happen weekly.
  • Releases to self-hosted sites happen monthly:
    • Scheduled release: July 7, 2026
    • Code freeze: July 6, 2026

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.

@github-actions github-actions Bot added the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label Jun 23, 2026
@jp-launch-control

jp-launch-control Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 1 file.

File Coverage Δ% Δ Uncovered
projects/plugins/jetpack/class.jetpack-gutenberg.php 251/540 (46.48%) 7.29% 2 ❤️‍🩹

Full summary · PHP report · JS report

kraftbj added 5 commits June 22, 2026 21:59
Code review (ce-code-review) found two real defects in the lazy registration:

1. Inner dynamic blocks: pre_render_block fires for an inner block *after*
   core has already built its WP_Block (resolving block_type), so registering
   a deferred dynamic block only at its own inner pre_render_block was too late
   and its render_callback was skipped (e.g. jetpack/button nested in another
   block). Fix: walk the full parsed subtree at the top-level pre_render_block
   so nested deferred blocks register before any inner WP_Block is constructed.

2. Drop blocks that aren't safe to defer:
   - videopress registers `jetpack/videopress-block` (name != directory), so the
     dirname-keyed handler never matched and it silently failed to register.
   - calendly, opentable, send-a-message use plan_check; their availability is
     computed from the init-time jetpack_register_gutenberg_extensions hook,
     which lazy registration bypasses, so a co-located eager plan_check block
     could memoize stale availability and the deferred block would render nothing.

Deferred set is now 25 blocks. Tests cover the subtree walk and inner-invocation skip.

See JETPACK-1747.
…nclude

Codex adversarial review found two real (Medium) gaps:

1. is_block_editor_context() missed index-permalink REST URLs
   (/index.php/wp-json/...), so on sites without pretty permalinks a
   /wp/v2/block-types request would be treated as front-end and the lazy
   blocks would be omitted from the editor's block-type list. Now matches both
   the /wp-json/ and /index.php/wp-json/ forms.

2. slideshow is require_once'd by modules/shortcodes/slideshow.php (AMP path)
   after init. If that include ran before a jetpack/slideshow block rendered,
   the lazy loader's include_once would be a no-op and the captured-init-callback
   registration would never fire, so the block would silently render static.
   Removed slideshow from the deferred set and documented the constraint.

Deferred set is now 24 blocks.

See JETPACK-1747.
Round-2 adversarial review found that a deferred block placed inside a synced
pattern / reusable block (core/block) would render blank on the front end: core
stores the pattern's content in a separate wp_block post that render_block_core_block
only parses at render time, so it is absent from the top-level parsed tree the
subtree walk sees, and the inner blocks are then built before their own
pre_render_block fires.

Fix: when the subtree walk encounters a core/block, resolve its `ref` and recurse
into the referenced post's parsed blocks (with a seen-refs cycle guard), so any
deferred block inside the pattern is registered before core builds its WP_Block.

See JETPACK-1747.
Round-3 review (Codex): button.php defines Button\render_email(), which
subscriptions.php and class-jetpack-memberships.php call for WooCommerce e-mail
rendering. Both calls are function_exists-guarded so there is no fatal, and the
e-mail path normally runs in eager (cron/REST/admin) contexts — but the
dependency is exactly the out-of-band-file-dependency exclusion criterion this
patch documents, so deferring button is removed to stay consistent and safe.

Also document why core/navigation (wp_navigation ref) is intentionally not
resolved in the subtree walk (no editor path places deferred blocks there;
resolving it would add a get_post/parse_blocks on nearly every page).

Deferred set is now 23 blocks.

See JETPACK-1747.
Round-4 review: podcast-player and tiled-gallery register a render_email_callback,
which the WooCommerce e-mail editor reads off the registered block type. That
renderer does not go through pre_render_block/do_blocks, and an e-mail containing
the block can be rendered on a front-end request (e.g. a transactional e-mail sent
during checkout), where the block would otherwise be deferred and unregistered —
so its e-mail markup would silently fall back to default. Same class of out-of-band
registry consumer as the button render_email dependency, so exclude these too and
document the criterion.

Verified no other deferred block has a render_email_callback or an out-of-band
namespace reference. Deferred set is now 21 blocks.

See JETPACK-1747.
@kraftbj

kraftbj commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Measured opcache impact

Measured with get_included_files() on real HTTP loopback requests, on a connected site with only the Blocks module active (the reported customer configuration). Baseline = trunk @ 0dc71733c9 (this branch's merge-base).

Request trunk loads this PR defers
Front page 422 files / 4,797 KB −21 files / −59 KB
wp-admin · editor 508 / 5,419 KB · 531 / 5,936 KB unchanged (the editor still registers all blocks)

This is the largest file-count reduction in the wave (relevant to the opcache.max_accelerated_files limit, not just memory). It lazily registers ~21 pure-display blocks until first render — top contributors:

  • extensions/blocks/pinterest/pinterest.php — 8.9 KB
  • extensions/blocks/eventbrite/eventbrite.php — 8.0 KB
  • extensions/blocks/like/like.php — 5.6 KB
  • _inc/lib/class-jetpack-top-posts-helper.php — 5.1 KB
  • extensions/blocks/top-posts/top-posts.php — 4.3 KB
  • extensions/blocks/business-hours/business-hours.php — 4.0 KB
  • extensions/blocks/blog-stats/blog-stats.php — 3.6 KB
  • … plus image-compare, gif, google-calendar, related-posts, tock, voice-to-content, nextdoor, repeat-visitor, goodreads, blogging-prompt, sharing-buttons, payments-intro, markdown (14 more).

opcache stores compiled bytecode (~2–6× source size), so the real per-front-page opcache saving is roughly 0.12–0.35 MB.

Part of the JETPACK-1747 deferral wave; combined total in #49836.

kraftbj added 3 commits June 23, 2026 09:29
Resolve conflicts in class.jetpack-gutenberg.php and its test with PR 49807,
which landed its own is_block_editor_context() + $frontend_editor_extensions for
the editor-extension slice. Keep a single is_block_editor_context() (this branch's
superset: it also detects index-permalink REST URLs and treats an empty REQUEST_URI
as eager), keep 49807's $frontend_editor_extensions and load_block_editor_extensions()
gating, and keep this branch's lazy block registration. Tests are the union of both
branches' additions (this branch's lazy-registration tests verify all of 49807's
is_block_editor_context data-provider cases plus the index-permalink and empty-URI cases).
- PHP 8.5: ReflectionMethod/ReflectionProperty::setAccessible() is deprecated
  (no-op since 8.1) and the suite runs under E_ALL, so the unconditional calls
  in the reflection test helpers failed. Guard them with PHP_VERSION_ID < 80100,
  matching the existing data-provider test.
- Static analysis: test_lazy_register_ignores_inner_block_invocations passed a
  stdClass where lazy_register_deferred_block() documents \WP_Block|null. Pass a
  real WP_Block instead.

See JETPACK-1747.
… branch

Two high-signal tests covering the previously-uncovered production code:

- test_load_and_register_runs_deferred_block_init_callback: drives
  load_and_register_deferred_block() end-to-end via a throwaway fixture block,
  exercising the file include + $wp_filter['init'] capture/run/remove path.
- test_load_independent_blocks_defers_lazy_blocks_on_frontend: forces a
  front-end context and asserts a lazy block is deferred (not loaded) and the
  pre_render_block just-in-time handler is registered.

See JETPACK-1747.
@kraftbj kraftbj marked this pull request as ready for review June 24, 2026 15:59
@kraftbj kraftbj requested a review from LiamSarsfield June 24, 2026 15:59

@LiamSarsfield LiamSarsfield left a comment

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.

Nice slice. The mechanism reads cleanly, and I traced the render paths plus all 21 allowlist entries: the pure-display set checks out and I couldn't find a reachable front-end regression. A separate Codex pass reached the same conclusion on its own. Three things I'd want before this becomes the base for the dynamic-blocks follow-up:

1. Put a mechanical guard under the allowlist. The design rests on $lazy_blocks staying "pure" per the six rules in the docblock, but nothing in CI checks that. And three of the new tests (walks_inner_blocks, resolves_synced_pattern_reference, attempts_a_deferred_block_only_once) use a does-not-exist feature, so they bail at the file_exists guard and stay green even if the loader breaks. Could you add a data-provider test over the real $lazy_blocks that, per entry, asserts the file exists, registers exactly jetpack/<feature> and nothing else, and adds no render_email_callback / plan_check / REST route / post meta? That turns the prose contract into a test that fails the day someone breaks a rule.

2. Add one real end-to-end test. Drop a fixture lazy block in, run do_blocks() on <!-- wp:jetpack/... --> under a front-end REQUEST_URI, and assert it registered and produced output. Worth covering the related-posts priority-9 case while you're there. Nothing drives the actual pre_render_block → include → init-replay path through core right now.

3. Make a missed registration loud in debug. If the include is a no-op (file already loaded elsewhere) or the file moved, the block drops off the page with no signal. A WP_DEBUG-gated _doing_it_wrong() at the post-include-still-unregistered and file-missing branches would make a future allowlist mistake greppable instead of invisible.

One non-blocking note: a deferred pure block reads as available: false from get_cached_availability() until it renders. Harmless today, since the only front-end consumer reads non-deferred blocks and the sharing-buttons reader is admin-only, but it's the same stale-availability trap your plan_check exclusion already guards. Worth a line in the docblock so a future front-end availability consumer doesn't walk into it.

Everything else I found was polish (a couple of doc tags, the core/navigation edge the editor can't produce) and not worth the churn.

@kraftbj

kraftbj commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

@LiamSarsfield addressed your review in 1bee99a:

  • Added a data-provider guard over the real lazy-block allowlist: each entry must have a matching file, exactly one Blocks::jetpack_register_block() call, exactly one init registration callback, a directory-matching jetpack/<feature> name, and no render_email_callback, plan_check, REST route, or post meta registration.
  • Reworked the three missing-file bookkeeping tests to use real throwaway fixture blocks and assert the lazy loader actually registers them, including nested InnerBlocks and synced-pattern refs.
  • Added a do_blocks() end-to-end test for the real pre_render_block -> include -> init replay path that asserts rendered output, plus a related-posts case covering its priority-9 callback.
  • Added WP_DEBUG-gated _doing_it_wrong() notices for missing registration files and no-op includes that add no new init callback. I intentionally used the no-new-callback signal rather than a blanket still-unregistered warning because guarded allowlist blocks can legitimately decline registration under their module/connection checks.
  • Added the get_cached_availability() caveat to the allowlist docblock.

Local checks: php -l, git diff --check, and phpcs-changed --git --git-unstaged --standard=Jetpack ... pass. Focused local PHPUnit did not reach the suite because this Docker WordPress test checkout is currently incomplete/corrupted; CI should be the authoritative PHPUnit signal here.

@kraftbj kraftbj requested a review from LiamSarsfield June 24, 2026 18:02

@LiamSarsfield LiamSarsfield left a comment

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.

My pass and a separate Codex pass converged on the same short list. The three I'd act on:

One-line CI fix. test_load_and_register_warns_when_deferred_block_file_is_missing triggers _doing_it_wrong() without a matching setExpectedIncorrectUsage(), so it fails post-conditions under WP_DEBUG, where the suite runs. Add:

$this->setExpectedIncorrectUsage( 'Jetpack_Gutenberg::warn_about_deferred_block_registration_failure' );

Worth confirming on CI, since the local run didn't happen.

Give the contract test teeth, and google-docs-embed is the reason. It's on the lazy list and defines map_gsuite_url(), which the WPCOM Google Docs REST endpoint calls. Safe today because REST stays eager, but it's a listed block that breaks the "no out-of-band consumers" rule, and the contract test passes it. The test checks four string patterns and a name assertion that reduces to X === X, so it can't see a helper another file calls. Two ways to close it: move the test to a behavioral check (include the file, snapshot WP_Block_Type_Registry, assert one jetpack/<feature> and nothing else), or document google-docs-embed as a known exception and add an endpoint regression test.

Restore module state in the priority-9 test. test_do_blocks_lazy_registers_related_posts_priority_9_callback activates the related-posts module and never restores the active-module list, so it leaks into later tests in the same process. Snapshot Jetpack::get_active_modules() and restore it in the finally.

The rest I'd let go as polish: changelog wording, subdirectory REST cases, the reflection-helper consolidation, the @return void tags.

Two optional follow-ups, neither blocking. A one-line invalidate of self::$cached_availability after a lazy registration would close the documented availability caveat instead of only noting it. And available_jetpack_blocks reports the 21 blocks unavailable on a front-end inline sync, which is a separate behavior delta and likely its own PR.

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

Labels

[Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Status] In Progress [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants