Injecting a Class into core/group Based on a Child Heading Level, Using render_block_data

The symptom is familiar: a publishing team wants one group of blocks to look different because it opens with an h2, and another group to look different because it opens with an h3. They do not want to edit every post, and they do not want an editor-side convention that contributors will forget. They want the server to decide at render time.

The obvious move is to filter render_block and run a regular expression over the HTML. That works until it doesn’t: the regex matches a heading inside a nested group, or inside a column, or inside a block that renders its own heading from a template, and the class lands on the wrong wrapper. The less obvious move — filtering render_block_data and inspecting the parsed block tree — is more precise, provided you understand what is and is not in that tree when the filter runs.

Where render_block_data sits in the pipeline

WordPress documents render_block_data as filtering “the block being rendered in render_block(), before it’s processed” (developer.wordpress.org). The hook was introduced in 5.1.0; the $parent_block argument was added in 5.9.0. Its signature is:

apply_filters( 'render_block_data', array $parsed_block, array $source_block, WP_Block|null $parent_block )

The core function render_block() applies this filter to the parsed block, then constructs a WP_Block and calls render() on it. The source of render_block() shows the order plainly: $parsed_block = apply_filters( 'render_block_data', $parsed_block, $source_block, $parent_block ); comes before new WP_Block( $parsed_block, $context ) and before $block->render() (developer.wordpress.org).

Inner blocks get the same treatment. WP_Block::render() iterates over inner_content, and for each inner block it applies render_block_data to that inner block’s parsed data before rendering it (developer.wordpress.org). So the filter fires for the parent and for each descendant, with $parent_block set when the block is nested.

This timing is the whole reason the technique works. By the time render_block fires, you have a string of HTML and must reverse-engineer structure from markup. By contrast, render_block_data hands you the parsed block array — blockName, attrs, innerBlocks, innerHTML, innerContent — as documented on the hook reference. The heading-level check becomes a structural test on arrays, not a string search.

The shape of the parsed block

The hook reference lists the keys explicitly: blockName (string or null), attrs (array from block comment delimiters), innerBlocks (an array of arrays with the same structure), innerHTML (string from inside the delimiters), and innerContent (a list of string fragments and null markers where inner blocks were found). A user-contributed note on the same page shows a concrete example of that nesting, including a child block inside innerBlocks with its own innerHTML and innerContent.

Two consequences follow. First, innerBlocks is already parsed when the parent’s render_block_data callback runs. You do not need to call parse_blocks() yourself. Second, innerBlocks contains parsed arrays, not rendered HTML. If you want to know whether a group contains a heading at a given level, you inspect the child’s blockName and attrs, not the child’s output.

For core/heading, the level is stored in the level attribute. The core blocks reference lists level among the Heading block’s attributes (developer.wordpress.org). In parsed data it appears as $child['attrs']['level']. Treat a missing level as “not the target level” unless you have verified your site’s saved content always includes it; the safe default is to require an explicit match.

A minimal callback

The following is a complete, runnable filter. It targets core/group, scans the group’s direct innerBlocks for a core/heading whose level equals a chosen integer, and appends a class to the group’s className attribute. It does not recurse; the limitation is discussed below.

<?php
/**
 * Add a class to core/group when it directly contains a core/heading
 * at a specific level.
 *
 * @param array $parsed_block The parsed block being rendered.
 * @return array Modified parsed block.
 */
function jv_group_class_for_heading_level( $parsed_block ) {
    if ( empty( $parsed_block['blockName'] ) || 'core/group' !== $parsed_block['blockName'] ) {
        return $parsed_block;
    }

    $target_level = 3;
    $class        = 'has-h3-lead';
    $found        = false;

    if ( ! empty( $parsed_block['innerBlocks'] ) && is_array( $parsed_block['innerBlocks'] ) ) {
        foreach ( $parsed_block['innerBlocks'] as $inner ) {
            if ( empty( $inner['blockName'] ) || 'core/heading' !== $inner['blockName'] ) {
                continue;
            }
            if ( isset( $inner['attrs']['level'] ) && (int) $inner['attrs']['level'] === $target_level ) {
                $found = true;
                break;
            }
        }
    }

    if ( ! $found ) {
        return $parsed_block;
    }

    if ( empty( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ) {
        $parsed_block['attrs'] = array();
    }

    $existing = isset( $parsed_block['attrs']['className'] ) ? (string) $parsed_block['attrs']['className'] : '';
    $classes  = preg_split( '/\s+/', trim( $existing ) );
    $classes  = array_filter( $classes );

    if ( ! in_array( $class, $classes, true ) ) {
        $classes[] = $class;
    }

    $parsed_block['attrs']['className'] = implode( ' ', $classes );

    return $parsed_block;
}
add_filter( 'render_block_data', 'jv_group_class_for_heading_level' );

Why className rather than editing innerHTML or innerContent? Because className is a block attribute consumed by normal block rendering. The hook reference’s own user-contributed example sets $parsed_block['attrs']['className'] and returns the modified array, which is the same pattern used here. Mutating innerHTML would bypass the attribute system and risk producing markup that disagrees with what the block’s save function expects.

Why not render_block?

render_block filters the content of a single block and receives $block_content, $block, and $instance (developer.wordpress.org). It is the documented tool for post-render HTML changes. The Block Filters guide notes that render_block “has no impact on the behavior of blocks in the Editor” and demonstrates adding a class to Paragraph blocks with WP_HTML_Tag_Processor rather than regex (developer.wordpress.org).

For this problem, render_block is the wrong layer. You would have to parse the group’s rendered HTML to find a heading and read its tag name. That is possible with the HTML API, but it is more work and more fragile than reading innerBlocks before rendering. Use render_block_data when the decision depends on block structure; use render_block when the decision depends on final markup that only exists after rendering.

Failure modes to test

The minimal callback above is correct for its stated scope. It is not a general solution. Test these cases before trusting it in production.

Groups with no heading

The callback returns the block unchanged. No class is added. This is the intended behavior, but verify it with a group that contains only paragraphs and images.

Headings nested inside inner groups or columns

A top-level-only scan of innerBlocks will miss a heading inside a nested core/group, core/columns, or core/column. The parsed tree is recursive; the loop above is not. If your editorial pattern routinely nests groups, either recurse or accept that the class applies only to groups whose heading is a direct child.

A recursive variant is straightforward: walk innerBlocks depth-first and return true on the first matching heading. The trade-off is that a group containing a nested group with an h3 will receive the class even if its own direct heading is an h2. Decide which semantics you want before writing the recursion.

Headings whose level attribute is absent

If attrs.level is not set, the callback treats the heading as not matching. That is deliberate. Do not assume a default level; confirm against your saved content. A quick WP-CLI query can show whether any core/heading blocks in the database lack a level attribute:

wp db query "SELECT ID, post_title FROM wp_posts WHERE post_content LIKE '%wp:heading%' AND post_content NOT LIKE '%\"level\"%' AND post_status = 'publish' LIMIT 20;"

Adjust the table prefix if your install does not use wp_. If the query returns rows, those posts contain heading blocks without an explicit level, and the callback will not match them.

Dynamic blocks and innerBlocks

The hook reference describes innerBlocks as parsed from the block comment delimiters. For blocks whose content is generated at render time, the parsed array may not contain the inner structure you expect. The evidence available here does not establish a general rule for every dynamic block, so verify on your own install: log $parsed_block inside the callback for a page that uses the block in question and inspect innerBlocks before relying on it.

Debugging the callback

Do not guess the parsed structure. Log it. The following temporary addition writes the parsed block to the PHP error log when a group is encountered:

if ( 'core/group' === $parsed_block['blockName'] ) {
    error_log( print_r( $parsed_block, true ) );
}

Load the page with WP_DEBUG_LOG enabled and read wp-content/debug.log. Confirm three things: that blockName is core/group, that innerBlocks contains the heading you expect, and that the heading’s attrs.level is the integer you are matching. If the log shows the heading at a deeper level than the loop scans, that is the bug, not the filter.

Query Monitor is useful here for a different reason: it shows which hooks fired and in what order, which helps confirm that render_block_data is running on the front end and not only in some other context. It will not show you the parsed array contents; the error log does that.

What this approach does not do

It does not change the editor. The class is added during front-end rendering and is not saved to post content. Contributors will not see it in the block inspector, and the block’s saved markup will not contain it. If you need editor-visible styling, this is the wrong tool; look at editor-side filters or a block style variation instead.

It does not persist. Every page load re-evaluates the condition. That is usually desirable for a rule based on content structure, but it means the class disappears if the heading is removed, which is the point.

It does not replace render_block for cases where the decision depends on rendered HTML. The two hooks solve different problems at different stages.

Verification checklist

After deploying the callback, verify in this order:

  1. Load a page containing a group with a direct h3. Inspect the group wrapper in DevTools. The class should appear alongside the existing wp-block-group class.
  2. Load a page containing a group with a direct h2. The class should not appear.
  3. Load a page containing a group with no heading. The class should not appear.
  4. Load a page containing a group with a nested group that contains an h3. With the non-recursive callback, the outer group should not receive the class. If it does, your recursion or your scan is broader than intended.
  5. Check the error log for PHP notices from the callback. An undefined index warning on attrs or level means a guard is missing.

If step 1 fails, log $parsed_block and compare the actual structure to the assumptions above. The filter is reliable; the assumptions about the tree are where the bugs live.