perf(player): avoid DomCrawler parse on every singular pageview#585
Merged
Conversation
auto_prepend_player() is hooked to the_content at priority 1000000 for all post types, so it runs on every uncached singular front-end request. It called has_custom_player() -- which builds a full Symfony DomCrawler (a libxml parse of the entire post content) plus two XPath queries -- before render_player()'s cheap Player::is_enabled() gate. That parse ran even when no player could ever render: unconfigured sites, Player UI = Disabled, Embed = None, unsupported post types, or posts with no audio. Add two cheap short-circuits ahead of the parse: 1. auto_prepend_player() now checks get_post() instanceof WP_Post && is_enabled( $post ) before calling has_custom_player(). This mirrors render_player()'s own early return, so returning $content here is exactly equivalent to prepending its '' -- verified behaviour- preserving across the full (is_singular, is_enabled, has_custom_player) truth table. It intentionally does NOT gate on Base::check, since render_player() still fires the beyondwords_player_html filter (with empty HTML) when enabled but no renderer matches. 2. has_custom_player() guards the DomCrawler with a str_contains() pre-check for the 'data-beyondwords-player' attribute and the SDK URL. Both XPath queries require one of those literal substrings in the markup, so their absence guarantees no match and we bail without parsing. is_enabled() bottoms out in cached get_post_meta()/get_option() reads, so the new gate is far cheaper than the DOM parse it replaces on the hot path. No phpcs:ignore added; passes WordPress-VIP-Go. Add regression tests: the previously-untested SDK-<script> detection path, a URL-in-text-only case (guard passes, XPath still returns false), and the disabled-player short-circuit in auto_prepend_player. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
galbus
marked this pull request as ready for review
July 19, 2026 07:08
gouravkhunger
approved these changes
Jul 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Player::auto_prepend_player()is hooked tothe_contentat priority1000000for all post types, so it runs on every uncached singular front-end request. It calledhas_custom_player()— which constructs a fullSymfony\Component\DomCrawler\Crawler(a libxmlDOMDocumentparse of the entire, unbounded post content) plus two XPath queries — beforerender_player()'s cheapPlayer::is_enabled()gate.That parse ran on the hot path even when no player could ever render — i.e. virtually every request whose content lacks a player marker:
On large posts / high-traffic VIP sites this is measurable, avoidable render-time work on every uncached request, purely to detect a marker string that is almost always absent.
The fix
Two cheap short-circuits ahead of the parse:
auto_prepend_player()now checksget_post() instanceof WP_Post && self::is_enabled( $post )before callinghas_custom_player().has_custom_player()guards theCrawlerwith astr_contains()pre-check fordata-beyondwords-playerand the SDK URL, so the DOM parse only runs when a marker is plausibly present.Why it's behaviour-preserving
auto_prepend_player()mirrorsrender_player()'s own early return (return ''when! $post instanceof WP_Post || ! is_enabled()). Returning$contentat the new gate is therefore exactly equivalent to prepending that''. Verified across the full(is_singular, is_enabled, has_custom_player)truth table — identical output in every case.Base::check/renderer eligibility (the "ideally" suggestion in the report):render_player()still fires thebeyondwords_player_htmlfilter with empty HTML when a post is enabled but no renderer matches, and short-circuiting there would silently drop that filter contract. The substring guard already makes that path cheap, sois_enabledis the correct gate.data-beyondwords-playerattribute name or the SDK URL in asrcto appear in the source, so their absence guarantees no match.is_enabled()bottoms out in cachedget_post_meta()/get_option()reads — far cheaper than the DOM parse it now front-runs.Tests
WordPress-VIP-Go): clean, nophpcs:ignoreadded.PlayerTest:OK (46 tests, 101 assertions). Added 3 regression cases:<script>detection path,false— proves the guard is a pre-filter, not a replacement),auto_prepend_player().Reviewer notes
str_containsrequires PHP ≥ 8.0, which the plugin already mandates (and already uses elsewhere, e.g.class-sync.php,class-client.php).🤖 Generated with Claude Code