Make WordPress Core

Opened 4 weeks ago

Last modified 4 weeks ago

#66062 new defect (bug)

_restore_wpautop_hook() re-adds wpautop at priority -1 when a later the_content callback runs the_content again

Reported by: BigBugsy Owned by:
Priority: normal Milestone: Awaiting Review
Component: Editor Version: 5.2
Severity: normal Keywords: has-patch has-unit-tests has-test-info has-screenshots
Cc: Focuses:

Description (last modified by BigBugsy)

Summary

If a shortcode (or any the_content callback) calls apply_filters( 'the_content', ... ) on a block post, WordPress ends up with wpautop registered at priority -1 for the rest of the request. Every post rendered after that point on the same page is run through wpautop twice: once on the raw block markup and once on the rendered blocks. The visible result is stray </p> tags, empty paragraphs, and <br /> after hidden inputs in things like Query Loop > Post Content, reusable blocks, and form plugin blocks that appear later on the page.

The post with the nesting shortcode looks fine. The damage lands in unrelated content further down the page. In my case it showed up as broken form markup in a reusable block below the article, and it took a while to trace back to a table of contents shortcode in the post body.

This is a follow-up to #45495 / r45139, which fixed nested the_content calls that start inside do_blocks(). This ticket covers a different ordering: with the default hook priorities, a shortcode starts a nested the_content run at priority 11, before the pending _restore_wpautop_hook callback at that same priority.

Real-world trigger: Easy Table of Contents (​easy-table-of-contents#982).

Steps to reproduce

No plugins or theme needed. Save this as repro.php and run wp eval-file repro.php --skip-plugins --skip-themes:

<?php
// Any callback that runs the_content while the_content is already running.
add_shortcode( 'nested', function () {
        return apply_filters( 'the_content', 'Nested text.' );
} );

$post_a = "<!-- wp:paragraph -->\n<p>[nested]</p>\n<!-- /wp:paragraph -->";
$post_b = "<!-- wp:html -->\n<form action=\"/\">\n<input type=\"hidden\" name=\"a\" value=\"1\">\n<input type=\"hidden\" name=\"b\" value=\"2\">\n<button>Send</button>\n</form>\n<!-- /wp:html -->";

echo "wpautop priority before: ", var_export( has_filter( 'the_content', 'wpautop' ), true ), "\n";
echo "Post B before:\n", apply_filters( 'the_content', $post_b ), "\n";

apply_filters( 'the_content', $post_a );

echo "wpautop priority after:  ", var_export( has_filter( 'the_content', 'wpautop' ), true ), "\n";
echo "Post B after:\n", apply_filters( 'the_content', $post_b ), "\n";

Output on 7.1:

wpautop priority before: 10
Post B before:

<form action="/">
<input type="hidden" name="a" value="1">
<input type="hidden" name="b" value="2">
<button>Send</button>
</form>

wpautop priority after:  -1
Post B after:
</p>
<form action="/">
<input type="hidden" name="a" value="1"><br />
<input type="hidden" name="b" value="2"><br />
<button>Send</button><br />
</form></p>

Same content, filtered before and after post A. The only difference is that post A was rendered in between.

To see it in a browser on stock Twenty Twenty-Five: put [nested] in post A, an HTML block like $post_b in post B, and add a Query Loop > Post Content block showing post B to the single template. View post A.

Why it happens

  1. do_blocks() (priority 9) sees blocks in post A, so it removes wpautop from 10 and adds _restore_wpautop_hook at 11.
  2. At priority 11, WP_Hook loops over do_shortcode and then _restore_wpautop_hook. PHP foreach iterates a copy of the array, so removing callbacks during the loop does not change what it visits.
  3. do_shortcode runs [nested], which starts an inner the_content run. The inner run reaches priority 11 and calls _restore_wpautop_hook, which puts wpautop back at 10 and removes itself.
  4. Back in the outer loop, _restore_wpautop_hook is called a second time from the stale copy. has_filter( 'the_content', '_restore_wpautop_hook' ) now returns false, so $current_priority - 1 evaluates to -1, and wpautop is added at priority -1.
  5. From then on, every the_content run autops twice: at -1 on raw block markup, and at 10 because do_blocks() only removes the first wpautop it finds (the -1 copy) and leaves the 10 copy in place.

r45139 does not help here because the nested run does not start inside do_blocks(); it starts from a sibling callback after the restore hook is already queued.

Proposed fix

Make _restore_wpautop_hook() a no-op when it is no longer registered, which means an inner run already restored wpautop:

--- a/src/wp-includes/blocks.php
+++ b/src/wp-includes/blocks.php
@@ function _restore_wpautop_hook( $content ) {
        $current_priority = has_filter( 'the_content', '_restore_wpautop_hook' );
 
+       if ( false === $current_priority ) {
+               return $content;
+       }
+
        add_filter( 'the_content', 'wpautop', $current_priority - 1 );
        remove_filter( 'the_content', '_restore_wpautop_hook', $current_priority );
 
        return $content;
 }

With this guard the script above prints priority 10 both times and post B renders identically. Verified on a 7.1 site and in a standalone WP_Hook simulation.

​PR #13427 contains the guard and regression tests covering nested plain-text and block content, restoration of the original wpautop registration, and subsequent block and classic content formatting.

Attachments (1)

ticket-66062-pr-13427-after.png​ (161.1 KB ) - added by arkaprabhachowdhury 4 weeks ago.
Chrome screenshot showing the PR-specific Playground reproduction passing with before=10, after=10, and clean form output.

Download all attachments as: .zip

Change History (8)

This ticket was mentioned in ​PR #13427 on ​WordPress/wordpress-develop by ​dustinatx.


4 weeks ago
#1

  • Keywords has-unit-tests added; needs-unit-tests removed

Fixes https://core.trac.wordpress.org/ticket/66062

When a shortcode recursively applies the_content at priority 11, before
the pending _restore_wpautop_hook callback, the inner run restores
wpautop and removes the restore hook. The outer hook iteration still
invokes its stale callback. has_filter() then returns false, and
subtracting one registers wpautop at priority -1, causing unwanted
paragraph formatting in content rendered later in the same request.

This change makes _restore_wpautop_hook() return the content unchanged
when it is no longer registered.

The regression test covers nested plain-text and block content. It checks
that wpautop is registered only at its original priority, that the
temporary restore hook is removed, and that subsequent block and classic
content receive the appropriate formatting.

Validation:

  • Tests_Blocks_Render: 108 tests, 147 assertions, all passing.
  • Both regression cases fail against the original core code, showing wpautop registered at both -1 and 10.
  • PHPCS passes for both changed files.

AI assistance: Yes
Tool(s): Claude Code; Codex
Model(s): Claude Fable 5.1; GPT-6 Astra
Used for:

  • Claude Code: Investigation of the hook-state sequence, the reproduction script, a first draft of the guard and unit test, and the Trac ticket text.
  • Codex: Independent review of the ticket and fix, expanded regression coverage, test and coding-standard verification, and revisions to the commit message and PR description.

I reviewed the final change and validation results and take responsibility
for this contribution.

#2 @BigBugsy
4 weeks ago

  • Description modified (diff)

#3 @BigBugsy
4 weeks ago

Opened ​PR #13427 with the guard and regression tests.

The tests cover nested plain-text and block content, verify that wpautop is registered only at its original priority, and check subsequent block and classic content formatting.

Local validation of commit b2e2aae:

  • Tests_Blocks_Render: 108 tests, 147 assertions, all passing.
  • Both regression cases fail against the original core code: wpautop is registered at both -1 and 10 instead of only 10.
  • PHPCS passes for both changed files.

One correction to the description: "priority 11 or higher" is too broad. With the default hook priorities, the reproduced failure occurs when the nested call starts at priority 11, before the pending _restore_wpautop_hook callback at that same priority. A callback at priority 12 did not reproduce this failure in the hook simulation.

The PR includes the AI-assistance disclosure. Feedback and independent testing are welcome.

​@BigBugsy commented on ​PR #13427:


4 weeks ago
#4

Could someone with write access rerun the failed PHP 8.5 / MariaDB 10.3 job? It failed during npm dependency installation with ECONNRESET, before reaching the PHP tests. The preceding deprecation warnings were not the cause.

​https://github.com/WordPress/wordpress-develop/actions/runs/34148640002/job/101826064008

​@BigBugsy commented on ​PR #13427:


4 weeks ago
#5

Could someone with write access rerun the failed PHP 8.5 / MariaDB 10.3 job? It failed during npm dependency installation with ECONNRESET, before reaching the PHP tests. The preceding deprecation warnings were not the cause.

​https://github.com/WordPress/wordpress-develop/actions/runs/34148640002/job/101826064008

#6 @BigBugsy
4 weeks ago

  • Description modified (diff)

@arkaprabhachowdhury
4 weeks ago

Chrome screenshot showing the PR-specific Playground reproduction passing with before=10, after=10, and clean form output.

#7 @arkaprabhachowdhury
4 weeks ago

  • Keywords has-test-info has-screenshots added

Test Report

Description

This report validates that PR #13427 addresses Trac #66062.

Patch tested: ​PR #13427 at 43ceea434d09bf5281d54c53cddcf6526ddfe9a0

Environment

  • WordPress: 7.2-alpha-20260907.213429
  • PHP: 7.4
  • Server: PHP.wasm through WordPress Playground
  • Browser: Chrome
  • OS: Windows
  • Theme: Twenty Twenty-Five
  • MU Plugins: None activated
  • Plugins: None activated

Steps to Test

  1. Opened the PR-specific Playground: ​https://playground.wordpress.net/wordpress.html?pr=13427
  2. Confirmed the WordPress admin frame loaded.
  3. Opened Playground Tools -> Terminal -> PHP.
  4. Ran the ticket's nested the_content reproduction.
  5. Checked the wpautop priority and subsequent form output.

Expected Results

  • wpautop remains registered at priority 10.
  • Subsequent content is not double-wrapped.
  • No stray </p> or <br /> markup appears around form controls.

Actual Results

  • [pass] The PR-specific Playground loaded successfully in Chrome.
  • [pass] The reproduction reported before=10 and after=10.
  • [pass] The subsequent form output contained only the expected form, hidden inputs, and button.
  • [pass] No stray </p> or <br /> markup appeared.

The attached screenshot shows the reproduction and passing terminal output.

Note: See TracTickets for help on using tickets.