Blocks: lazily register pure display blocks on first render (JETPACK-1747)#49840
Blocks: lazily register pure display blocks on first render (JETPACK-1747)#49840kraftbj wants to merge 13 commits into
Conversation
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.
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
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:
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:
If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. |
Code Coverage SummaryCoverage changed in 1 file.
|
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.
Measured opcache impactMeasured with
This is the largest file-count reduction in the wave (relevant to the
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. |
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.
LiamSarsfield
left a comment
There was a problem hiding this comment.
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.
|
@LiamSarsfield addressed your review in 1bee99a:
Local checks: |
LiamSarsfield
left a comment
There was a problem hiding this comment.
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.
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) andinclude_onces every block's registration PHP — ~46 files — on every request, including front-end pages that contain no Jetpack blocks. Each block then callsregister_block_type()oninit.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-typesendpoint, and server-side rendering are unchanged.How it works
is_block_editor_context()— early front-end-vs-editor detection. Returnstruefor admin/REST/cron/CLI/XML-RPC (and any non-web context with noREQUEST_URI),falseonly 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 therest_routequery var) because this runs before core definesREST_REQUEST.load_independent_blocks()skips the files in$lazy_blocksand registers onepre_render_blockfilter.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'sblock_typewhen it constructs that innerWP_Block, which is before the inner block's ownpre_render_blockfires — so registering a deferred dynamic block only at its own inner filter would be too late and itsrender_callbackwould be skipped. Synced patterns / reusable blocks (core/block) keep their content in a separatewp_blockpost that core only parses at render time, so the walk resolves therefand recurses (with a cycle guard).load_and_register_deferred_block()include_onces the block file and runs theinitcallback the include adds (captured by diffing$wp_filter['init']->callbacks), sinceinithas already fired by render time.Safety — what is NOT deferred
A block is only deferred if its
initcallback does nothing butBlocks::jetpack_register_block()(plus trivial guards), it registers exactly one block type namedjetpack/<dir>, and it has no out-of-band consumer. Excluded blocks keep loading eagerly. Notable exclusions (and why), all encoded in the$lazy_blocksdocblock: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— registersjetpack/videopress-block(name ≠ directory), so a dirname-keyed lazy handler would never match it.calendly,opentable,send-a-message— useplan_check; their availability is computed from the init-timejetpack_register_gutenberg_extensionshook, which lazy registration bypasses.slideshow— its block file isrequired afterinitbymodules/shortcodes/slideshow.php, which would make the lazyinclude_oncea no-op and skip registration.button—subscriptions/membershipscallButton\render_email()(defined inbutton.php) out-of-band for WooCommerce e-mail rendering.podcast-player,tiled-gallery— register arender_email_callbackread off the block type by the WooCommerce e-mail editor, an out-of-band renderer that does not go throughpre_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:
extensions/blocks/*/*.php)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.
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
jp build js-packages/social-logos && jp build plugins/jetpack, activate Jetpack with the Blocks module on (offline mode is fine).get_included_files()vs trunk:pre_render_block). Also verify a deferred block placed inside a synced pattern renders correctly (exercises thecore/blockref resolution)./wp-admin/post-new.php— all Jetpack blocks must still appear in the inserter (block-editor context loads eagerly).Jetpack_Gutenberg_Test(gate detection, subtree walk, synced-pattern resolution, inner-invocation skip).Other information
plugins/jetpack, typeother).wp_register_block_metadata_collection()and the APIs used here are always available; no capability guard needed.