Ignore stray closing P tags when tracking the open element stack - #2651
Conversation
`wpautop()` wraps two `[caption]` shortcodes sharing a paragraph in a single `P`, and `do_shortcode()` then expands each into a `FIGURE`, producing `<p><figure>…</figure><br><figure>…</figure></p>`. The first `FIGURE` implicitly closes the `P` (see `self::P_CLOSING_TAGS`), which leaves the explicit `</p>` stray. `next_token()` popped unconditionally for that closer, taking an unrelated ancestor off the stack. Every subsequent breadcrumb was then one level short, so the computed XPaths no longer described the DOM the browser builds, and the tag stack mismatch warning fired for the rest of the document. Since XPaths are the keys used to match URL Metrics back to elements, nothing after the stray closer could be matched, silently disabling the optimizations that depend on those matches. Per the HTML spec, an end tag for `P` with no `P` element in scope closes nothing; an empty `P` element is implied at that position instead. Ignore the closer and advance the sibling index at the current level so that implied element occupies its slot, keeping following siblings aligned with the DOM. The guard is limited to a closer with no open `P` anywhere on the stack, so shapes such as `<p><em></p>`, where a closer should pop more than one element, keep their existing behavior. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## trunk #2651 +/- ##
==========================================
+ Coverage 70.35% 70.50% +0.15%
==========================================
Files 91 91
Lines 7867 7870 +3
==========================================
+ Hits 5535 5549 +14
+ Misses 2332 2321 -11
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds support/tests for handling a stray closing </p> (as produced by wpautop() with caption shortcodes expanding into FIGURE) so that computed sibling indices/XPaths remain aligned with the browser’s DOM.
Changes:
- Added a new sample document fixture covering the “stray closing
</p>” scenario in HTML tag processing tests. - Introduced an integration test-case fixture (
buffer.html,expected.html,set-up.php) to validate XPath alignment and LCP-based attribute injection. - Updated
OD_HTML_Tag_Processor::next_token()to account for the implied emptyPelement at the stray closing</p>position by incrementing sibling indices.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/optimization-detective/tests/test-class-od-html-tag-processor.php | Adds a new data-provider case for stray closing </p> and expected breadcrumb indices. |
| plugins/optimization-detective/tests/test-cases/stray-closing-p/set-up.php | Registers URL metrics and a visitor to set fetchpriority based on computed XPath. |
| plugins/optimization-detective/tests/test-cases/stray-closing-p/expected.html | Snapshot of expected output with data-od-xpath and fetchpriority injection. |
| plugins/optimization-detective/tests/test-cases/stray-closing-p/buffer.html | Input buffer representing wpautop() output for the stray closing </p> scenario. |
| plugins/optimization-detective/class-od-html-tag-processor.php | Adjusts index bookkeeping for stray closing </p> to keep computed XPaths DOM-aligned. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The stray closing P tag handling repeated the initialize-or-increment logic used when pushing an open tag onto the stack. Two copies of the sibling index semantics can drift, so move it into `advance_open_stack_index()` and call that from both places. Also cover the branch where the implied empty P element is the first child at its depth, which the existing case did not reach, along with consecutive stray closers implying more than one empty P element. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Aditya Dhade <76063440+b1ink0@users.noreply.github.com>
Summary
Fixes #2650
wpautop()wraps two[caption]shortcodes that share a paragraph in a singleP, anddo_shortcode()then expands each into aFIGURE:OD_HTML_Tag_Processoralready handles the first half correctly:FIGUREis inself::P_CLOSING_TAGS, so the openingPis implicitly closed. It got the second half wrong. The now-stray</p>fell through to the generic closer branch and popped unconditionally, taking the enclosingDIVoff the stack.Per the HTML spec, an end tag for
Pwith noPelement in scope closes nothing — a parse error is raised and an emptyPelement is implied at that position instead. Confirmed againstWP_HTML_Processoron the same fragment:Why this matters beyond the log noise
The stack stayed one level short for the remainder of the document, so every XPath computed after the stray closer was wrong. On the homepage of my test site, 8 of the 12
data-od-xpathvalues lost theMAINlevel entirely and picked up the wrongARTICLEindex:XPaths are the keys used to match URL Metrics back to elements, so no element after the stray closer could be matched and the optimizations depending on those matches silently did not apply.
Relevant technical choices
Advancing the sibling index is not cosmetic. The browser's DOM does contain the implied empty
P, so it occupies a sibling slot. Without the bump, a following sibling computes*[3]where the DOM has it at position 4, and the mismatch just moves rather than disappears. The new snapshot case pins this: the hero image after the stray closer resolves to*[6][self::IMG], matching the XPath a browser reports, so the stored LCP element matches andfetchpriority="high"is applied. Before this change it computed*[5], the lookup missed, and no attribute was added.The guard is deliberately narrow. It fires only when there is no open
Panywhere on the stack — a genuinely stray closer. Shapes such as<p><em></p>, where a closer should pop more than one element, keep their existing behavior; that broader divergence between our rudimentary stack tracking and the spec is #2622, and #1546 remains the wholesale alternative.Out of scope. This fixes the input that was emitting notices, not the fact that notices raised inside the output buffer callback are swallowed. On PHP 8.5 those surface as:
because PHP 8.5 deprecates and discards output produced from within a user output handler — which is why the underlying notice was invisible in the browser and only reached
debug.log. Adopting core's template enhancement output buffer, which installs its own error handler, addresses that: #2224 / #2516 for Optimization Detective, #2225 for Server-Timing.Testing
stray-closing-pcase indata_provider_sample_documents, assertingFOOTERlands at*[6].stray-closing-psnapshot test case exercising the URL Metric match end to end.actual.htmlwas regenerated for any of them.optimization-detective: 358 tests, 12,323 assertions.image-prioritizer: 91 tests.embed-optimizer: 66 tests.phpcsandphpstanclean.MAINlevel.Use of AI Tools
Claude Opus 5, via Claude Code, did the root-cause analysis, wrote the patch, and wrote the tests. I reviewed the diagnosis and the change, and take responsibility for it. The analysis was cross-checked against
WP_HTML_Processoras the spec-compliant reference rather than taken on assertion.