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 )
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
do_blocks()(priority 9) sees blocks in post A, so it removeswpautopfrom 10 and adds_restore_wpautop_hookat 11.- At priority 11,
WP_Hookloops overdo_shortcodeand then_restore_wpautop_hook. PHPforeachiterates a copy of the array, so removing callbacks during the loop does not change what it visits. do_shortcoderuns[nested], which starts an innerthe_contentrun. The inner run reaches priority 11 and calls_restore_wpautop_hook, which putswpautopback at 10 and removes itself.- Back in the outer loop,
_restore_wpautop_hookis called a second time from the stale copy.has_filter( 'the_content', '_restore_wpautop_hook' )now returnsfalse, so$current_priority - 1evaluates to-1, andwpautopis added at priority -1. - From then on, every
the_contentrun autops twice: at -1 on raw block markup, and at 10 becausedo_blocks()only removes the firstwpautopit 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)
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
#3
@
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
@
4 weeks ago
Chrome screenshot showing the PR-specific Playground reproduction passing with before=10, after=10, and clean form output.
#7
@
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
- Opened the PR-specific Playground: https://playground.wordpress.net/wordpress.html?pr=13427
- Confirmed the WordPress admin frame loaded.
- Opened Playground Tools -> Terminal -> PHP.
- Ran the ticket's nested
the_contentreproduction. - Checked the
wpautoppriority and subsequent form output.
Expected Results
wpautopremains registered at priority10.- 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=10andafter=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.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
Fixes https://core.trac.wordpress.org/ticket/66062
When a shortcode recursively applies
the_contentat priority 11, beforethe pending
_restore_wpautop_hookcallback, the inner run restoreswpautopand removes the restore hook. The outer hook iteration stillinvokes its stale callback.
has_filter()then returnsfalse, andsubtracting one registers
wpautopat priority -1, causing unwantedparagraph formatting in content rendered later in the same request.
This change makes
_restore_wpautop_hook()return the content unchangedwhen it is no longer registered.
The regression test covers nested plain-text and block content. It checks
that
wpautopis registered only at its original priority, that thetemporary 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.wpautopregistered at both -1 and 10.AI assistance: Yes
Tool(s): Claude Code; Codex
Model(s): Claude Fable 5.1; GPT-6 Astra
Used for:
I reviewed the final change and validation results and take responsibility
for this contribution.