Dynamic blocks and the Interactivity API
Server-Rendered Blocks
A dynamic block produces its markup on every request. What the render callback receives, why the wrapper function is not optional, and what must be null.
You copy a block you built in the previous module, decide that the list it draws should
come from the database rather than from stored markup, and add a render callback. The block
renders. It renders without its background colour, without the alignment class the editor
shows, and without the wp-block-mytheme-list class every stylesheet in the theme
is targeting. The content is right and the block looks like it belongs to nobody.
Nothing about that failure is mysterious once you know that a server-rendered block
produces its wrapper from scratch. The editor's half of the contract is
useBlockProps.save(), and a dynamic block never runs save. The
server's half is a function you have to call in your callback, and if you do not call it,
the wrapper is whatever markup you typed.
A render callback is a function of attributes, and render.php is one
Two fields turn a block into a dynamic one. In the manifest, "render":
"file:./render.php" names a template. In PHP, a render_callback key in
the arguments to register_block_type() names a function. The manifest route is
implemented as the callback route: when WordPress reads the metadata it sets
render_callback to a closure that starts an output buffer, requires
the template, and returns what the buffer collected.
// wp-includes/blocks.php, abbreviated.
$settings['render_callback'] = static function ( $attributes, $content, $block ) use ( $template_path ) {
ob_start();
require $template_path;
return ob_get_clean();
};
That closure is the whole explanation of what your template can use. render.php
is included inside a function, so $attributes, $content and
$block are local variables in scope rather than globals, and anything the
template echos becomes the block's output. The same file included from a theme
template part would not have them.
A block registered for a manifest that points at a missing file is dynamic and renders
nothing, silently. register_block_type_from_metadata() only assigns the closure
when realpath() resolves, which is worth remembering the first time a callback
appears not to run.
The three arguments are not interchangeable
$attributes is the array the block parser read from the comment delimiter,
after prepare_attributes_for_render() has validated it and filled in defaults.
$content is a string: the already-rendered inner blocks when the block wraps
them, and an empty string otherwise. $block is a WP_Block instance,
which is where the context and the block instance's own data live.
<?php
/**
* @var array $attributes
* @var string $content Rendered inner blocks.
* @var WP_Block $block Block instance.
*/
$inner = trim( $content );
$post_id = $block->context['postId'] ?? 0;
$count = count( $block->inner_blocks );
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
<?php echo $inner; ?>
</div>
Register a callback that echoes gettype( $content ) and
get_class( $block ) and a block with no inner blocks answers
string and WP_Block every time. The string is not the saved
markup of the block. A dynamic block's own saved markup is not passed to the callback at
all, which is a different symptom of the same fact: the server is producing the block's
output, so the copy in the post is not consulted.
$block->inner_blocks is the array of child instances and
$block->inner_content the raw chunks, so a wrapper can count or restructure
its children. $block->context carries the values an ancestor block
publishes through usesContext, plus postId and
postType when a post is being rendered. That is why the same callback produces
different output on a single post and in a query loop.
get_block_wrapper_attributes() is how the block gets its identity
Every support you declared in the manifest that produces markup — colour, spacing,
typography, border, alignment, anchor — writes into one function's return value.
get_block_wrapper_attributes() reads the block currently being rendered from
WP_Block_Supports::$block_to_render, applies every support, merges the result
with anything you pass it, and returns a string of attributes ready to echo.
<ul class="wp-block-m4lab-callback has-text-color has-background has-accent-1-background-color alignwide"
style="color:#ff0000;padding-top:2rem" id="section-two" data-probe="m4lab">
That output came from a callback that declared align, color,
spacing and anchor and echoed the function onto a
<ul>. Preset slugs became classes, free values became inline styles,
alignment and anchor became attributes, and the extra attribute passed as an argument came
through untouched. The wrapper's class list is also where wp-block-{namespace}-{name}
comes from, so a callback that skips the function produces a block the style engine cannot
target and a theme cannot style from the outside.
Called outside a render, it returns nothing and warns
Call get_block_wrapper_attributes() from init, from a REST
callback, or from any code that is not rendering a block, and WordPress 7.0.7 emits
Trying to access array offset on null from
class-wp-block-supports.php and returns an empty string. Both the warning and
the empty block class point at the same cause: there is no current block. Calling it inside
a shortcode that a block happens to contain is the usual way to meet this.
save must be null, and what happens when it is not
A dynamic block must not have a save function that returns markup. Not
returning anything is the requirement; returning null explicitly is the same
thing. The reason is that the two halves would both claim authorship of the same element.
The server produces the block's output on every request, so any markup the editor stored is
discarded at render time — and a block whose stored markup is never used is a block whose
stored markup is dead weight that can drift.
There is no server-side check for this. WP_Block_Type::is_dynamic() is
is_callable( $this->render_callback ) and nothing more, so a block with both
a render callback and a save registers happily and renders from the callback.
The failure lands in the editor, as a validation failure, because the stored markup and the
result of running save are compared and the block was never meant to have
either. Recovery rewrites the markup from the parsed attributes, and the next render ignores
it again.
The practical consequence is a debugging rule. When a dynamic block renders old content
that you thought you had changed, look for a save function you forgot to remove
or a cache, in that order, and then look at the callback. When the editor reports invalid
content on a block that has a render callback, read the manifest before you write a
deprecation. There is nothing to migrate to.
render_block_data rewrites the input, render_block rewrites the output
Two filters wrap the render. render_block_data runs before the block
instance is built, receives the parsed block, an unmodified copy of it, and the parent block
instance, and returns the array that will be rendered. Change
$parsed_block['attrs'] there and the callback receives the changed attributes,
because the instance is constructed from what you return.
add_filter(
'render_block_data',
function ( $parsed_block, $source_block, $parent_block ) {
if ( 'mytheme/list' === $parsed_block['blockName']
&& ! current_user_can( 'edit_posts' ) ) {
unset( $parsed_block['attrs']['showDrafts'] );
}
return $parsed_block;
},
10,
3
);
render_block runs after the callback, receives the finished HTML, the parsed
block, and the WP_Block instance, and returns the HTML that is printed. It sees
what the callback produced and nothing else, so it cannot recover information the callback
discarded. Use it to wrap, annotate or strip output that comes from a block you do not own,
and use render_block_data when the block's own code should behave differently.
The parent parameter is what makes either filter usable on a nested block. Inside a query
loop, $parent_block->attributes tells you which loop you are in, so you can
change how a post excerpt renders on an archive and leave it alone inside a template part.
Both filters also run once per block instance, which is why they are the right place for
per-request work and the wrong place for anything expensive that could be computed once.
Register the same block twice. The first registration is a manifest with
"render": "file:./render.php" and supports for anchor,
color and spacing; the second is a plain
render_callback added in PHP. Both render a list of rows from an attribute
called rows.
Render both from the command line with do_blocks(), once with the wrapper
function echoed into the opening tag and once without it. Write down every difference in
the output, including the class list and any inline style.
Then add a render_block_data filter that appends a row to
rows for one block name only, and a render_block filter that
adds a data-rendered attribute to the opening tag of the other. Show that
the first changes what the callback sees and the second changes only the string that is
printed.
Finish by nesting the wrapper block around a paragraph and printing
$content, count( $block->inner_blocks ) and
$block->context['postId'] from inside the callback.
Show the solution and the reasoning
Without the wrapper function the opening tag is exactly what you typed, which for a
callback that echoes <ul> means no wp-block-m4lab-callback
class, no preset classes, no inline styles, no id from the anchor support and
no alignment class. An empty string is returned whenever the block has no support output
and no extra attributes, so the difference is between
<ul data-probe="m4lab"> and the full tag. Both render the rows and only
one can be styled.
Because $attributes is prepared against the schema declared in the
manifest, the two registrations must declare the same attributes or the PHP-only one
receives nothing for rows. A dynamic block registered without an
attributes key is not an error; the parsed attributes are passed through
unvalidated and un-defaulted, which is a quiet way to get null where you
expected an empty array.
$content = '<!-- wp:m4lab/nested -->'
. '<!-- wp:paragraph --><p>Inner one</p><!-- /wp:paragraph -->'
. '<!-- /wp:m4lab/nested -->';
echo do_blocks( $content );
Inside that callback $content is the rendered paragraph, so
trim( $content ) is not empty and
count( $block->inner_blocks ) is 1.
$block->context['postId'] is 0 when do_blocks() runs from the
command line with no post in scope, and the post's ID when the same block is rendered in a
page. That difference has nothing to do with the block and everything to do with where the
render was triggered.
The filter demonstration is worth doing in that order because it shows the asymmetry.
render_block_data can change behaviour: the callback sees the extra row and
renders it, so the output changes for a reason the block's own code chose.
render_block can only change appearance: the callback rendered two rows and
the filter made the tag say data-rendered, which is enough for a cache-busting
attribute or a theme hook and not enough to make the block do anything differently.
One ordering detail explains a surprise people hit when they combine this with the
Interactivity API. The server processes directives in a dynamic block with
supports.interactivity after the render_block filters have run,
so a filter sees unresolved data-wp-text attributes and cannot read the value
the server is about to write into them. Filter the attributes at
render_block_data if what you need is the truth before rendering, and treat
the finished HTML as presentation.
The remaining limit is the one that makes a dynamic block worth the trouble. Nothing invalidates the callback's output when the data behind it changes, so the block is only as fresh as the cache in front of it, and a page cache will happily serve yesterday's rows. That is a deployment problem rather than a block problem, and naming the cache before the bug report is most of the diagnosis.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.