.js path long before
* `script_loader_tag` (priority 20/30) runs, so the delay + exclusion
* checks only ever see the hashed URL. A user targeting a script by
* URL substring — the obvious thing to do, and what the UI invites —
* would silently stop matching the moment minification was enabled.
* Minifier::rewrite_script() records the original here so those
* checks can test both. (FBS field report against 1.1.2)
*
* @var array
*/
private static $original_src = array();
/**
* Record a script's URL as it was BEFORE minification rewrote it.
* Called from Minifier::rewrite_script().
*
* @param string $handle Script handle.
* @param string $src Original (pre-minify) URL.
*/
public static function remember_original_src( string $handle, string $src ): void {
if ( '' !== $handle && '' !== $src ) {
self::$original_src[ $handle ] = $src;
}
}
/**
* The pre-minify URL for a handle, or '' when we never rewrote it
* (external script, minification off, or a handle we didn't touch).
*
* @param string $handle Script handle.
*/
public static function original_src( string $handle ): string {
return isset( self::$original_src[ $handle ] ) ? self::$original_src[ $handle ] : '';
}
/**
* Reset the remembered URLs. Test-only seam.
*/
public static function reset_original_src(): void {
self::$original_src = array();
}
/**
* Does this tag (or attribute string) opt out of optimization?
*
* `data-no-optimize` / `data-no-minify` are the de-facto convention
* consent managers and other plugins print so optimizers keep hands
* off (Borlabs Cookie stamps both on its config script). The CSS
* combine buffer has honored `data-no-optimize` from the start; the
* JS paths did not, so a marked consent script was still minified
* into a hashed cache file — and a stale copy of a legally relevant
* consent config is a correctness problem, not a cosmetic one. (#456)
*
* @param string $tag A full tag, or just its attribute string.
*/
public static function tag_opts_out( string $tag ): bool {
return (bool) preg_match( '#\sdata-no-(?:optimize|minify)\b#i', $tag );
}
/**
* Pristine tags as they looked before any of our transforms, keyed by
* handle. See snapshot_tag() / revert_late_marked_tag().
*
* @var array
*/
private static $pristine_tag = array();
/**
* Priority for the late opt-out re-check. Past Borlabs' ScriptBlocker
* at 999 — the highest stamper we have seen in the wild — so the
* marker has certainly landed by the time we look. (#469)
*/
private const LATE_OPT_OUT_PRIORITY = 1000;
/**
* The priority the late opt-out re-check runs at.
*
* A site whose stamper hooks even later can move ours past it.
*/
public static function late_opt_out_priority(): int {
/**
* Filter the priority of xSpeed's late data-no-optimize re-check.
*
* @param int $priority Default 1000.
*/
return (int) apply_filters( 'xspeed_late_opt_out_priority', self::LATE_OPT_OUT_PRIORITY );
}
/**
* Filter: `script_loader_tag`, priority 9 — remember the tag before we
* touch it, so a marker stamped later can still be honored.
*
* Our three opt-out-aware transforms run at 15/20/30. A plugin that
* stamps `data-no-optimize` AFTER them is invisible to all three:
* Borlabs Cookie stamps at priority 100, so its consent config was
* still minified into a hashed cache file AND delayed — the script
* that has to run before anything else on the page ran only on first
* interaction. Snapshotting here is what lets the late pass put the
* original back verbatim, rather than trying to unpick each transform
* in reverse. (#469)
*
* @param string $tag
* @param string $handle
* @param string $src
*/
public static function snapshot_tag( $tag, $handle, $src ): string {
if ( is_string( $tag ) && '' !== $tag && '' !== (string) $handle ) {
self::$pristine_tag[ (string) $handle ] = $tag;
}
return (string) $tag;
}
/**
* Filter: `script_loader_tag`, priority `LATE_OPT_OUT_PRIORITY` — hand
* back the untouched tag when a late filter stamped an opt-out marker
* after our transforms had already run.
*
* The priority has to clear the stamper, not merely the transforms:
* Borlabs stamps at 100 and Borlabs' own script blocker at 999, so an
* earlier hook reads a tag whose marker has not landed yet. PHP_INT_MAX
* would be unfriendly to a site that legitimately wants the last word,
* so this sits just past the highest stamper we know of and is
* filterable. Reverting to the snapshot is deliberate: undoing
* a delay rewrite in place would mean re-deriving `src` from
* `data-xs-src` and stripping markers, and #273 is a standing reminder
* that regex-editing these attributes in reverse goes wrong quietly.
*
* The pristine tag still carries whatever priority-10 filters did to
* it, so only OUR changes are dropped. (#469)
*
* @param string $tag
* @param string $handle
* @param string $src
*/
public static function revert_late_marked_tag( $tag, $handle, $src ): string {
if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) {
return (string) $tag;
}
$handle = (string) $handle;
$pristine = isset( self::$pristine_tag[ $handle ] ) ? self::$pristine_tag[ $handle ] : '';
if ( '' !== $pristine && $pristine !== $tag ) {
// The marker is on the tag we were handed, not on the snapshot,
// so carry it — and everything else the late filter set in the
// same pass — over. A consumer reading the rendered HTML (or
// our own buffer passes) must still see the opt-out it asked
// for.
$tag = self::copy_late_attributes( $tag, $pristine, $handle );
}
// The snapshot was taken on `script_loader_tag`, by which point
// `script_loader_src` (priority 10) had ALREADY swapped in the
// hashed cache URL — so reverting the tag alone still leaves the
// minified src behind, which is the half the client actually
// reported. Undo that here too, using the URL rewrite_script()
// recorded. (#469)
return self::restore_marked_script_src( $tag, $handle, self::current_src( $tag, (string) $src ) );
}
/**
* The src currently on a tag, falling back to the one WordPress passed.
*
* After a revert the tag carries the snapshot's src, which is not
* necessarily the `$src` argument this late in the chain.
*
* @param string $tag Tag to read.
* @param string $fallback Value to use when the tag has no src.
*/
private static function current_src( string $tag, string $fallback ): string {
$open = self::open_tag_offsets( $tag );
if ( null !== $open
&& preg_match( '#(?= 0; $i-- ) {
$add = self::late_attribute_delta( $late_tags[ $i ]['attrs'], $to_tags[ $i ]['attrs'], $handle );
if ( '' !== $add ) {
$out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 );
}
}
return $out;
}
/**
* Fallback when the late tag and the snapshot cannot be paired
* positionally: copy only the attributes that protect the script from
* optimizers — the opt-out markers plus `data-cfasync` — onto every
* snapshot tag missing them. Values are taken as the stamper wrote
* them on the late tag. (#470)
*
* @param string $from Tag as the late filter left it.
* @param string $to Snapshot tag to stamp onto.
* @param array $to_tags open_tags() result for $to.
*/
private static function stamp_protective_attributes( string $from, string $to, array $to_tags ): string {
$protect = array();
foreach ( array( 'data-no-optimize', 'data-no-minify', 'data-cfasync' ) as $name ) {
if ( preg_match(
'#\s(' . preg_quote( $name, '#' ) . ')(\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#i',
$from,
$m
) ) {
$protect[ $name ] = ' ' . $name . ( isset( $m[2] ) ? $m[2] : '' );
}
}
if ( empty( $protect ) ) {
return $to;
}
$out = $to;
// Right to left: an earlier splice would shift every later offset.
for ( $i = count( $to_tags ) - 1; $i >= 0; $i-- ) {
$add = '';
foreach ( $protect as $name => $attr ) {
if ( ! preg_match( '#\s' . preg_quote( $name, '#' ) . '\b#i', $to_tags[ $i ]['attrs'] ) ) {
$add .= $attr;
}
}
if ( '' !== $add ) {
$out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 );
}
}
return $out;
}
/**
* The attributes present on the late tag but not the snapshot, minus
* the ones OUR transforms put there for this handle.
*
* Only attributes recorded in $our_late_attrs are dropped — a blanket
* defer/type skip threw away a defer the STAMPER set in the same pass
* as its marker. Themify prints main.js with defer + data-no-optimize
* together, and its config rides a deferred data: URI script printed
* just before it; stripping the theme's defer made main.js
* parser-blocking, so it ran ahead of its config and the theme died
* with "themify_vars is not defined". `src` is still never copied:
* it belongs to the snapshot, and restore_marked_script_src() owns
* undoing a minified URL.
*
* @param string $late_attrs Attribute string from the transformed tag.
* @param string $to_attrs Attribute string from the snapshot tag.
* @param string $handle Script handle the tags belong to.
*/
private static function late_attribute_delta( string $late_attrs, string $to_attrs, string $handle ): string {
$pattern = '#\s([-\w:]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#';
if ( ! preg_match_all( $pattern, $late_attrs, $late, PREG_SET_ORDER ) ) {
return '';
}
$have = array();
if ( preg_match_all( $pattern, $to_attrs, $existing, PREG_SET_ORDER ) ) {
foreach ( $existing as $attr ) {
$have[ strtolower( $attr[1] ) ] = true;
}
}
$add = '';
foreach ( $late as $attr ) {
$name = strtolower( $attr[1] );
if ( isset( $have[ $name ] ) || 'src' === $name || isset( self::$our_late_attrs[ $handle ][ $name ] ) ) {
continue;
}
if ( 0 === strpos( $name, 'data-xs-' ) ) {
continue;
}
$add .= $attr[0];
}
return $add;
}
/**
* Attribute names OUR transforms added in this request, keyed by
* handle: defer_script_tag() records `defer`, delay_script_tag()
* records `type` when it parks an inline block. Copying one of these
* from the late tag back onto the snapshot would re-apply the very
* transform the revert is undoing — but the same names coming from a
* STAMPER are the author's intent and must survive, so the skip is
* per-handle, never by name alone. `data-xs-*` is handled by prefix
* separately. (#469)
*
* @var array>
*/
private static $our_late_attrs = array();
/**
* Locate the opening `#is',
static function ( array $m ): string {
list( $whole, $attrs, $body ) = $m;
if ( '' === trim( $body ) ) {
return $whole;
}
// The tag itself asked to be left alone. (#456)
if ( self::tag_opts_out( $attrs ) ) {
return $whole;
}
// Our own replay bootstrap. Its body quotes the delay
// machinery's own strings, so a pathological user target
// fragment could match it — and a parked bootstrap means
// nothing on the page ever replays.
if ( false !== stripos( $attrs, 'xspeed-delay-bootstrap' ) ) {
return $whole;
}
// Already marked, or a real src= — the src passes own those.
// `(?' . $body . '';
},
$html
);
// A PCRE failure (backtrack limit on a huge inline body) returns
// null — and casting that to '' would serve AND cache a blank page.
// The unrewritten original is always the safe fallback.
return null === $out ? $html : $out;
}
/**
* Inline bootstrap that flips delayed scripts on the first user
* interaction. Printed once on wp_footer priority 1000.
*/
public static function print_delay_bootstrap(): void {
if ( self::skip_in_non_frontend_context() ) {
return;
}
if ( self::$delay_bootstrap_printed ) {
return;
}
self::$delay_bootstrap_printed = true;
// Failsafe timer for visitors who never interact. 0 disables it
// entirely (interaction-only), which is what lab tools measure
// best: a timer that fires inside Lighthouse's / GTmetrix's
// measurement window loads the "delayed" scripts anyway and
// inflates the reported TTI, so the delay looks ineffective.
$opts = self::opts();
$timeout = isset( $opts['delay_js_timeout'] ) ? (int) $opts['delay_js_timeout'] : 8000;
$timeout = max( 0, min( 60000, $timeout ) );
// The script is includes/js/delay-bootstrap.js, which also carries
// the design notes for the lifecycle replay (#494). `npm run build`
// minifies it into assets/delay-bootstrap.min.js; the timeout goes in
// place of its one placeholder.
//
// The tag goes out through wp_print_inline_script_tag(), like Free's
// other inline scripts, so a CSP plugin's wp_inline_script_attributes
// filter can give it a nonce. Printed bare, a nonce CSP blocked it
// and nothing was ever replayed.
$js = str_replace( 'XSPEED_DELAY_TIMEOUT', (string) $timeout, self::delay_bootstrap_js() );
wp_print_inline_script_tag( $js, array( 'id' => 'xspeed-delay-bootstrap' ) );
}
/** The delay bootstrap's code, read once per request. */
private static $delay_bootstrap_js = null;
/**
* The built delay bootstrap, without the line that records its source.
* If the build is missing, the readable source is valid JS too, only
* larger: printing nothing would leave every delayed script parked for
* good, because the tags are already rewritten by the time this runs.
*/
private static function delay_bootstrap_js(): string {
if ( null !== self::$delay_bootstrap_js ) {
return self::$delay_bootstrap_js;
}
$root = dirname( __DIR__ );
$built = $root . '/assets/delay-bootstrap.min.js';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local plugin file, not a remote URL.
$js = is_readable( $built ) ? (string) file_get_contents( $built ) : '';
if ( '' !== $js ) {
$js = (string) preg_replace( '#\A/\*[^\n]*\*/\n#', '', $js );
} else {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local plugin file, not a remote URL.
$js = (string) file_get_contents( $root . '/includes/js/delay-bootstrap.js' );
}
self::$delay_bootstrap_js = trim( $js );
return self::$delay_bootstrap_js;
}
/**
* Filter: `style_loader_tag` — wrap stylesheets in the
* print → onload="all" pattern so they download non-blocking.
* Pairs with critical CSS workflows. Adds a