Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
* Class Render_Blocking_JS
*/
class Render_Blocking_JS implements Feature, Changes_Output_On_Activation, Optimization {
/**
* Substring that marks an inline script as producing position-dependent
* output (document.write()/document.writeln()). Such scripts must stay where
* they are rather than being moved to the end of the document. The fast-path
* guard and the per-script check must use the same needle to stay in lockstep.
*
* @var string
*/
private const POSITION_DEPENDENT_OUTPUT_NEEDLE = 'document.write';

/**
* Holds the script tags removed from the output buffer.
*
Expand Down Expand Up @@ -214,7 +224,7 @@ public function handle_output_stream( $buffer_start, $buffer_end ) {
* @return array
*/
protected function get_script_tags( $buffer ) {
$regex = sprintf( '~<script(?![^>]*%s=(?<q>["\']*)%s\k<q>)([^>]*)>[\s\S]*?<\/script>~si', preg_quote( $this->ignore_attribute, '~' ), preg_quote( $this->ignore_value, '~' ) );
$regex = '~<script' . $this->ignore_attribute_lookahead() . '([^>]*)>[\s\S]*?<\/script>~si';
preg_match_all( $regex, $buffer, $script_tags, PREG_OFFSET_CAPTURE );

// No script_tags in the joint buffer.
Expand Down Expand Up @@ -249,13 +259,103 @@ protected function ignore_exclusion_scripts( $buffer ) {
'~<script\s+[^\>]*type=(?<q>["\']*)(application\/(ld\+)?json|importmap)\k<q>.*?>.*?<\/script>~si',
);

return preg_replace_callback(
$excluded = preg_replace_callback(
$exclusions,
function ( $script_match ) {
return $this->add_ignore_attribute( $script_match[0] );
},
$buffer
);
// preg_replace_callback() returns null on PCRE failure; keep the original
// buffer in that case rather than propagating null downstream.
if ( null !== $excluded ) {
$buffer = $excluded;
}

return $this->pin_position_dependent_scripts( $buffer );
}

/**
* Keep inline scripts whose output is position-dependent in their original place.
*
* Scripts using document.write()/document.writeln() insert markup at the script's
* location, so moving such a script to the end of the document renders its output
* after the footer instead of inside the content (e.g. a Custom HTML block).
* Marking the script with the ignore attribute keeps the rest of the pipeline
* from moving it. Scripts that already carry the ignore attribute are skipped so
* their behavior and markup are unchanged.
*
* Best-effort and deliberately conservative: it pins the common case (an inline
* script that calls document.write) and otherwise leaves the script to the
* default move behavior. It does not pin scripts that write their own
* '<script ...>' markup (no safe in-place edit exists), nor exotic call forms a
* substring check cannot see. A miss never corrupts the page — worst case is a
* script that still moves, exactly as it does without this method.
*
* @param string $buffer Captured piece of output buffer.
*
* @return string
*/
private function pin_position_dependent_scripts( $buffer ) {
// Fast path: skip the inline-script scan entirely when the buffer cannot
// contain a position-dependent script.
if ( false === stripos( $buffer, self::POSITION_DEPENDENT_OUTPUT_NEEDLE ) ) {
return $buffer;
}

// Match inline scripts only (no src attribute) that do not already carry
// the ignore attribute.
$inline_script_regex = '~<script\b(?![^>]*\ssrc\s*=)' . $this->ignore_attribute_lookahead() . '[^>]*>[\s\S]*?</script>~i';

$result = preg_replace_callback(
$inline_script_regex,
function ( $script_match ) {
// Intentionally conservative: a simple case-insensitive substring check
// for "document.write" (which also covers "document.writeln"). It does
// not parse JS, so exotic call forms it cannot see — document['write'](),
// "document . write()", or an uppercase <SCRIPT> tag that
// add_ignore_attribute()'s lowercase replace won't touch — simply fall
// back to the default behavior (the script is moved, as it is today).
// That is the safe direction: a miss never corrupts the page.
if ( false === stripos( $script_match[0], self::POSITION_DEPENDENT_OUTPUT_NEEDLE ) ) {
return $script_match[0];
}

// Do not touch a script that writes its own '<script ...>' markup. There
// is no safe in-place edit for it: add_ignore_attribute() does a global
// str_replace() on '<script', which rewrites the inner literal and can
// break the quoting of the string the script writes; tagging only the
// outer tag would instead let get_script_tags() match and move that inner
// literal. Such scripts keep the default behavior rather than risk
// corrupting the page.
if ( substr_count( strtolower( $script_match[0] ), '<script' ) > 1 ) {
return $script_match[0];
}

return $this->add_ignore_attribute( $script_match[0] );
},
$buffer
);

// preg_replace_callback() returns null on PCRE failure (e.g. backtrack limit
// on a pathological buffer); fall back to the unmodified buffer so the page is
// never blanked. Mirrors the guard in is_opened_script().
return null === $result ? $buffer : $result;
}

/**
* Negative lookahead asserting a <script> tag does not already carry the
* ignore attribute. Shared by the regexes that select movable scripts so the
* attribute-matching rule lives in one place.
*
* @return string Regex fragment (uses named group "q"; safe to use once per pattern).
*/
private function ignore_attribute_lookahead() {
return sprintf(
'(?![^>]*%s=(?<q>["\']*)%s\k<q>)',
preg_quote( $this->ignore_attribute, '~' ),
preg_quote( $this->ignore_value, '~' )
);
}

/**
Expand Down
4 changes: 4 additions & 0 deletions projects/plugins/boost/changelog/fix-defer-js-document-write
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Defer JS: keep position-dependent inline scripts (document.write) in place instead of moving them after the footer.
17 changes: 17 additions & 0 deletions projects/plugins/boost/tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@
*/
require_once __DIR__ . '/../vendor/autoload.php';

// PHP 8.0 polyfill: WordPress core polyfills str_contains() at runtime (WP 5.9+),
// but the unit suite runs without WordPress, so PHP <= 7.4 needs it here for the
// production code paths under test that call it.
if ( ! function_exists( 'str_contains' ) ) {
/**
* Polyfill for PHP 8.0's str_contains().
*
* @param string $haystack String to search in.
* @param string $needle Substring to search for.
* @return bool Whether $haystack contains $needle.
* @suppress PhanRedefineFunctionInternal -- Guarded polyfill for PHP < 8.0.
*/
function str_contains( $haystack, $needle ) {
return '' === $needle || false !== strpos( $haystack, $needle );
}
}

// Additional functions that brain/monkey doesn't currently define.
if ( ! function_exists( 'wp_unslash' ) ) {
/**
Expand Down
Loading
Loading