| @@ -82,8 +82,385 @@ | ||
| 82 | 82 | self::$original_src = array(); |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | 85 | /** |
| 86 | + * Does this tag (or attribute string) opt out of optimization? | |
| 87 | + * | |
| 88 | + * `data-no-optimize` / `data-no-minify` are the de-facto convention | |
| 89 | + * consent managers and other plugins print so optimizers keep hands | |
| 90 | + * off (Borlabs Cookie stamps both on its config script). The CSS | |
| 91 | + * combine buffer has honored `data-no-optimize` from the start; the | |
| 92 | + * JS paths did not, so a marked consent script was still minified | |
| 93 | + * into a hashed cache file — and a stale copy of a legally relevant | |
| 94 | + * consent config is a correctness problem, not a cosmetic one. (#456) | |
| 95 | + * | |
| 96 | + * @param string $tag A full tag, or just its attribute string. | |
| 97 | + */ | |
| 98 | + public static function tag_opts_out( string $tag ): bool { | |
| 99 | + return (bool) preg_match( '#\sdata-no-(?:optimize|minify)\b#i', $tag ); | |
| 100 | + } | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * Pristine tags as they looked before any of our transforms, keyed by | |
| 104 | + * handle. See snapshot_tag() / revert_late_marked_tag(). | |
| 105 | + * | |
| 106 | + * @var array<string,string> | |
| 107 | + */ | |
| 108 | + private static $pristine_tag = array(); | |
| 109 | + | |
| 110 | + /** | |
| 111 | + * Priority for the late opt-out re-check. Past Borlabs' ScriptBlocker | |
| 112 | + * at 999 — the highest stamper we have seen in the wild — so the | |
| 113 | + * marker has certainly landed by the time we look. (#469) | |
| 114 | + */ | |
| 115 | + private const LATE_OPT_OUT_PRIORITY = 1000; | |
| 116 | + | |
| 117 | + /** | |
| 118 | + * The priority the late opt-out re-check runs at. | |
| 119 | + * | |
| 120 | + * A site whose stamper hooks even later can move ours past it. | |
| 121 | + */ | |
| 122 | + public static function late_opt_out_priority(): int { | |
| 123 | + /** | |
| 124 | + * Filter the priority of xSpeed's late data-no-optimize re-check. | |
| 125 | + * | |
| 126 | + * @param int $priority Default 1000. | |
| 127 | + */ | |
| 128 | + return (int) apply_filters( 'xspeed_late_opt_out_priority', self::LATE_OPT_OUT_PRIORITY ); | |
| 129 | + } | |
| 130 | + | |
| 131 | + /** | |
| 132 | + * Filter: `script_loader_tag`, priority 9 — remember the tag before we | |
| 133 | + * touch it, so a marker stamped later can still be honored. | |
| 134 | + * | |
| 135 | + * Our three opt-out-aware transforms run at 15/20/30. A plugin that | |
| 136 | + * stamps `data-no-optimize` AFTER them is invisible to all three: | |
| 137 | + * Borlabs Cookie stamps at priority 100, so its consent config was | |
| 138 | + * still minified into a hashed cache file AND delayed — the script | |
| 139 | + * that has to run before anything else on the page ran only on first | |
| 140 | + * interaction. Snapshotting here is what lets the late pass put the | |
| 141 | + * original back verbatim, rather than trying to unpick each transform | |
| 142 | + * in reverse. (#469) | |
| 143 | + * | |
| 144 | + * @param string $tag | |
| 145 | + * @param string $handle | |
| 146 | + * @param string $src | |
| 147 | + */ | |
| 148 | + public static function snapshot_tag( $tag, $handle, $src ): string { | |
| 149 | + if ( is_string( $tag ) && '' !== $tag && '' !== (string) $handle ) { | |
| 150 | + self::$pristine_tag[ (string) $handle ] = $tag; | |
| 151 | + } | |
| 152 | + return (string) $tag; | |
| 153 | + } | |
| 154 | + | |
| 155 | + /** | |
| 156 | + * Filter: `script_loader_tag`, priority `LATE_OPT_OUT_PRIORITY` — hand | |
| 157 | + * back the untouched tag when a late filter stamped an opt-out marker | |
| 158 | + * after our transforms had already run. | |
| 159 | + * | |
| 160 | + * The priority has to clear the stamper, not merely the transforms: | |
| 161 | + * Borlabs stamps at 100 and Borlabs' own script blocker at 999, so an | |
| 162 | + * earlier hook reads a tag whose marker has not landed yet. PHP_INT_MAX | |
| 163 | + * would be unfriendly to a site that legitimately wants the last word, | |
| 164 | + * so this sits just past the highest stamper we know of and is | |
| 165 | + * filterable. Reverting to the snapshot is deliberate: undoing | |
| 166 | + * a delay rewrite in place would mean re-deriving `src` from | |
| 167 | + * `data-xs-src` and stripping markers, and #273 is a standing reminder | |
| 168 | + * that regex-editing these attributes in reverse goes wrong quietly. | |
| 169 | + * | |
| 170 | + * The pristine tag still carries whatever priority-10 filters did to | |
| 171 | + * it, so only OUR changes are dropped. (#469) | |
| 172 | + * | |
| 173 | + * @param string $tag | |
| 174 | + * @param string $handle | |
| 175 | + * @param string $src | |
| 176 | + */ | |
| 177 | + public static function revert_late_marked_tag( $tag, $handle, $src ): string { | |
| 178 | + if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) { | |
| 179 | + return (string) $tag; | |
| 180 | + } | |
| 181 | + $handle = (string) $handle; | |
| 182 | + $pristine = isset( self::$pristine_tag[ $handle ] ) ? self::$pristine_tag[ $handle ] : ''; | |
| 183 | + if ( '' !== $pristine && $pristine !== $tag ) { | |
| 184 | + // The marker is on the tag we were handed, not on the snapshot, | |
| 185 | + // so carry it — and everything else the late filter set in the | |
| 186 | + // same pass — over. A consumer reading the rendered HTML (or | |
| 187 | + // our own buffer passes) must still see the opt-out it asked | |
| 188 | + // for. | |
| 189 | + $tag = self::copy_late_attributes( $tag, $pristine ); | |
| 190 | + } | |
| 191 | + // The snapshot was taken on `script_loader_tag`, by which point | |
| 192 | + // `script_loader_src` (priority 10) had ALREADY swapped in the | |
| 193 | + // hashed cache URL — so reverting the tag alone still leaves the | |
| 194 | + // minified src behind, which is the half the client actually | |
| 195 | + // reported. Undo that here too, using the URL rewrite_script() | |
| 196 | + // recorded. (#469) | |
| 197 | + return self::restore_marked_script_src( $tag, $handle, self::current_src( $tag, (string) $src ) ); | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** | |
| 201 | + * The src currently on a tag, falling back to the one WordPress passed. | |
| 202 | + * | |
| 203 | + * After a revert the tag carries the snapshot's src, which is not | |
| 204 | + * necessarily the `$src` argument this late in the chain. | |
| 205 | + * | |
| 206 | + * @param string $tag Tag to read. | |
| 207 | + * @param string $fallback Value to use when the tag has no src. | |
| 208 | + */ | |
| 209 | + private static function current_src( string $tag, string $fallback ): string { | |
| 210 | + $open = self::open_tag_offsets( $tag ); | |
| 211 | + if ( null !== $open | |
| 212 | + && preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']*)["\']#i', $open['attrs'], $m ) ) { | |
| 213 | + return $m[1]; | |
| 214 | + } | |
| 215 | + return $fallback; | |
| 216 | + } | |
| 217 | + | |
| 218 | + /** | |
| 219 | + * Carry the attributes a late filter added onto the snapshot tag. | |
| 220 | + * | |
| 221 | + * Copying only `data-no-*` would silently drop the rest of what the | |
| 222 | + * stamper set in the same pass. Borlabs adds `data-cfasync="false"` | |
| 223 | + * alongside its markers — the attribute that keeps Cloudflare Rocket | |
| 224 | + * Loader off the consent config, i.e. the same class of breakage this | |
| 225 | + * fix exists to prevent, reintroduced by the fix itself. So diff the | |
| 226 | + * attribute names and bring over every one the snapshot lacks. | |
| 227 | + * | |
| 228 | + * Our own transform markers are excluded: they are what we are | |
| 229 | + * reverting, and re-adding `data-xs-delay` would re-delay the script. | |
| 230 | + * | |
| 231 | + * @param string $from Tag as the late filter left it. | |
| 232 | + * @param string $to Snapshot tag to stamp onto. | |
| 233 | + */ | |
| 234 | + private static function copy_late_attributes( string $from, string $to ): string { | |
| 235 | + $late_tags = self::open_tags( $from ); | |
| 236 | + $to_tags = self::open_tags( $to ); | |
| 237 | + if ( empty( $late_tags ) || empty( $to_tags ) || count( $late_tags ) !== count( $to_tags ) ) { | |
| 238 | + // Counts differ when a stamper/blocker injected or replaced a | |
| 239 | + // tag inside the concatenated string, or a transform dropped an | |
| 240 | + // inline block. Positional pairing is meaningless then — but | |
| 241 | + // returning the bare snapshot would silently strip the opt-out, | |
| 242 | + // and the buffer passes would re-optimize an unmarked tag: #469 | |
| 243 | + // again, on the mismatch path. Over-marking merely leaves a tag | |
| 244 | + // unoptimized, so stamp the protective attributes onto every | |
| 245 | + // snapshot tag instead. (#470) | |
| 246 | + return self::stamp_protective_attributes( $from, $to, $to_tags ); | |
| 247 | + } | |
| 248 | + // Pair the tags positionally and stamp each one from its own | |
| 249 | + // counterpart. A stamper runs over the whole concatenated string | |
| 250 | + // and may mark several of the tags in it; collapsing that onto one | |
| 251 | + // tag would strip the opt-out from the others, and the buffer | |
| 252 | + // passes re-test `tag_opts_out()` per tag, so an unmarked sibling | |
| 253 | + // is free to be re-optimized downstream — #469 again, one pass | |
| 254 | + // later. (#469) | |
| 255 | + $out = $to; | |
| 256 | + // Right to left: an earlier splice would shift every later offset. | |
| 257 | + for ( $i = count( $to_tags ) - 1; $i >= 0; $i-- ) { | |
| 258 | + $add = self::late_attribute_delta( $late_tags[ $i ]['attrs'], $to_tags[ $i ]['attrs'] ); | |
| 259 | + if ( '' !== $add ) { | |
| 260 | + $out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 ); | |
| 261 | + } | |
| 262 | + } | |
| 263 | + return $out; | |
| 264 | + } | |
| 265 | + | |
| 266 | + /** | |
| 267 | + * Fallback when the late tag and the snapshot cannot be paired | |
| 268 | + * positionally: copy only the attributes that protect the script from | |
| 269 | + * optimizers — the opt-out markers plus `data-cfasync` — onto every | |
| 270 | + * snapshot tag missing them. Values are taken as the stamper wrote | |
| 271 | + * them on the late tag. (#470) | |
| 272 | + * | |
| 273 | + * @param string $from Tag as the late filter left it. | |
| 274 | + * @param string $to Snapshot tag to stamp onto. | |
| 275 | + * @param array $to_tags open_tags() result for $to. | |
| 276 | + */ | |
| 277 | + private static function stamp_protective_attributes( string $from, string $to, array $to_tags ): string { | |
| 278 | + $protect = array(); | |
| 279 | + foreach ( array( 'data-no-optimize', 'data-no-minify', 'data-cfasync' ) as $name ) { | |
| 280 | + if ( preg_match( | |
| 281 | + '#\s(' . preg_quote( $name, '#' ) . ')(\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#i', | |
| 282 | + $from, | |
| 283 | + $m | |
| 284 | + ) ) { | |
| 285 | + $protect[ $name ] = ' ' . $name . ( isset( $m[2] ) ? $m[2] : '' ); | |
| 286 | + } | |
| 287 | + } | |
| 288 | + if ( empty( $protect ) ) { | |
| 289 | + return $to; | |
| 290 | + } | |
| 291 | + $out = $to; | |
| 292 | + // Right to left: an earlier splice would shift every later offset. | |
| 293 | + for ( $i = count( $to_tags ) - 1; $i >= 0; $i-- ) { | |
| 294 | + $add = ''; | |
| 295 | + foreach ( $protect as $name => $attr ) { | |
| 296 | + if ( ! preg_match( '#\s' . preg_quote( $name, '#' ) . '\b#i', $to_tags[ $i ]['attrs'] ) ) { | |
| 297 | + $add .= $attr; | |
| 298 | + } | |
| 299 | + } | |
| 300 | + if ( '' !== $add ) { | |
| 301 | + $out = substr_replace( $out, $add, $to_tags[ $i ]['attrs_end'], 0 ); | |
| 302 | + } | |
| 303 | + } | |
| 304 | + return $out; | |
| 305 | + } | |
| 306 | + | |
| 307 | + /** | |
| 308 | + * The attributes present on the late tag but not the snapshot, minus | |
| 309 | + * the ones our own transforms add. | |
| 310 | + * | |
| 311 | + * @param string $late_attrs Attribute string from the transformed tag. | |
| 312 | + * @param string $to_attrs Attribute string from the snapshot tag. | |
| 313 | + */ | |
| 314 | + private static function late_attribute_delta( string $late_attrs, string $to_attrs ): string { | |
| 315 | + $pattern = '#\s([-\w:]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*))?#'; | |
| 316 | + if ( ! preg_match_all( $pattern, $late_attrs, $late, PREG_SET_ORDER ) ) { | |
| 317 | + return ''; | |
| 318 | + } | |
| 319 | + $have = array(); | |
| 320 | + if ( preg_match_all( $pattern, $to_attrs, $existing, PREG_SET_ORDER ) ) { | |
| 321 | + foreach ( $existing as $attr ) { | |
| 322 | + $have[ strtolower( $attr[1] ) ] = true; | |
| 323 | + } | |
| 324 | + } | |
| 325 | + $add = ''; | |
| 326 | + foreach ( $late as $attr ) { | |
| 327 | + $name = strtolower( $attr[1] ); | |
| 328 | + if ( isset( $have[ $name ] ) || in_array( $name, self::OUR_TRANSFORM_ATTRS, true ) ) { | |
| 329 | + continue; | |
| 330 | + } | |
| 331 | + if ( 0 === strpos( $name, 'data-xs-' ) ) { | |
| 332 | + continue; | |
| 333 | + } | |
| 334 | + $add .= $attr[0]; | |
| 335 | + } | |
| 336 | + return $add; | |
| 337 | + } | |
| 338 | + | |
| 339 | + /** | |
| 340 | + * Attributes our own transforms add. Copying any of these from the | |
| 341 | + * transformed tag back onto the snapshot would re-apply the very | |
| 342 | + * transform we are undoing: | |
| 343 | + * | |
| 344 | + * defer/async — defer_script_tag() | |
| 345 | + * type — delay_script_tag() parks an inline block as | |
| 346 | + * text/xspeed-delayed; a type the author set is on the | |
| 347 | + * snapshot already and matches by name before we get here | |
| 348 | + * src — belongs to the snapshot, never to the late tag | |
| 349 | + * | |
| 350 | + * `data-xs-*` is handled by prefix separately. (#469) | |
| 351 | + */ | |
| 352 | + private const OUR_TRANSFORM_ATTRS = array( 'defer', 'async', 'type', 'src' ); | |
| 353 | + | |
| 354 | + /** | |
| 355 | + * Locate the opening `<script>` that carries the src, falling back to | |
| 356 | + * the last one when none does. | |
| 357 | + * | |
| 358 | + * WP_Scripts::do_item() hands `script_loader_tag` the concatenation of | |
| 359 | + * before_inline + external + after_inline, so the FIRST `<script` is | |
| 360 | + * routinely an inline block rather than the asset — the same trap | |
| 361 | + * #234 fixed for defer and #273 for delay. Scanning attribute-wise | |
| 362 | + * also means a quoted value containing `>` (an `onerror` guard, a JSON | |
| 363 | + * payload) cannot truncate the tag the way `[^>]*` did. (#469) | |
| 364 | + * | |
| 365 | + * @param string $tag Full tag string. | |
| 366 | + * @return array{attrs:string,attrs_end:int}|null | |
| 367 | + */ | |
| 368 | + private static function open_tag_offsets( string $tag ): ?array { | |
| 369 | + $tags = self::open_tags( $tag ); | |
| 370 | + $fallback = null; | |
| 371 | + foreach ( $tags as $found ) { | |
| 372 | + if ( preg_match( '#(?<![-\w])src\s*=#i', $found['attrs'] ) ) { | |
| 373 | + return $found; | |
| 374 | + } | |
| 375 | + $fallback = $found; | |
| 376 | + } | |
| 377 | + return $fallback; | |
| 378 | + } | |
| 379 | + | |
| 380 | + /** | |
| 381 | + * Every well-formed opening `<script>` in the string, in order. | |
| 382 | + * | |
| 383 | + * @param string $tag Full tag string. | |
| 384 | + * @return array<int,array{attrs:string,attrs_end:int}> | |
| 385 | + */ | |
| 386 | + private static function open_tags( string $tag ): array { | |
| 387 | + if ( ! preg_match_all( '#<script\b#i', $tag, $m, PREG_OFFSET_CAPTURE ) ) { | |
| 388 | + return array(); | |
| 389 | + } | |
| 390 | + $found = array(); | |
| 391 | + foreach ( $m[0] as $hit ) { | |
| 392 | + $start = (int) $hit[1] + strlen( $hit[0] ); | |
| 393 | + $end = self::scan_open_tag_end( $tag, $start ); | |
| 394 | + if ( null === $end ) { | |
| 395 | + continue; | |
| 396 | + } | |
| 397 | + $found[] = array( | |
| 398 | + 'attrs' => substr( $tag, $start, $end - $start ), | |
| 399 | + 'attrs_end' => $end, | |
| 400 | + ); | |
| 401 | + } | |
| 402 | + return $found; | |
| 403 | + } | |
| 404 | + | |
| 405 | + /** | |
| 406 | + * Offset of the `>` closing an opening tag, skipping any that sit | |
| 407 | + * inside a quoted attribute value. Null when the tag is unterminated. | |
| 408 | + * | |
| 409 | + * @param string $tag Full tag string. | |
| 410 | + * @param int $offset Index just past `<script`. | |
| 411 | + */ | |
| 412 | + private static function scan_open_tag_end( string $tag, int $offset ): ?int { | |
| 413 | + $len = strlen( $tag ); | |
| 414 | + $quote = ''; | |
| 415 | + for ( $i = $offset; $i < $len; $i++ ) { | |
| 416 | + $char = $tag[ $i ]; | |
| 417 | + if ( '' !== $quote ) { | |
| 418 | + if ( $char === $quote ) { | |
| 419 | + $quote = ''; | |
| 420 | + } | |
| 421 | + continue; | |
| 422 | + } | |
| 423 | + if ( '"' === $char || "'" === $char ) { | |
| 424 | + $quote = $char; | |
| 425 | + continue; | |
| 426 | + } | |
| 427 | + if ( '>' === $char ) { | |
| 428 | + // A self-closing `/>` keeps the slash out of the attributes. | |
| 429 | + return ( $i > $offset && '/' === $tag[ $i - 1 ] ) ? $i - 1 : $i; | |
| 430 | + } | |
| 431 | + } | |
| 432 | + return null; | |
| 433 | + } | |
| 434 | + | |
| 435 | + /** | |
| 436 | + * Filter: `script_loader_tag`, priority 15 — undo the minify-cache | |
| 437 | + * rewrite for a script whose printed tag opts out. | |
| 438 | + * | |
| 439 | + * The src rewrite happens on `script_loader_src` (priority 10), long | |
| 440 | + * before any plugin's own `script_loader_tag` filter can stamp | |
| 441 | + * `data-no-minify` onto the tag — so the marker arrived too late to | |
| 442 | + * prevent the rewrite. This runs after the filters that stamp at the | |
| 443 | + * default priority 10 and swaps the hashed cache URL back to the | |
| 444 | + * recorded original. A marker stamped later than our transforms is | |
| 445 | + * caught by revert_late_marked_tag() in the late pass instead. (#469) | |
| 446 | + * | |
| 447 | + * @param string $tag | |
| 448 | + * @param string $handle | |
| 449 | + * @param string $src | |
| 450 | + */ | |
| 451 | + public static function restore_marked_script_src( $tag, $handle, $src ): string { | |
| 452 | + if ( ! is_string( $tag ) || '' === $tag || ! self::tag_opts_out( $tag ) ) { | |
| 453 | + return (string) $tag; | |
| 454 | + } | |
| 455 | + $original = self::original_src( (string) $handle ); | |
| 456 | + if ( '' === $original || '' === (string) $src || false === strpos( $tag, (string) $src ) ) { | |
| 457 | + return $tag; | |
| 458 | + } | |
| 459 | + return str_replace( (string) $src, $original, $tag ); | |
| 460 | + } | |
| 461 | + | |
| 462 | + /** | |
| 86 | 463 | * Does a user-supplied target match this script? |
| 87 | 464 | * |
| 88 | 465 | * A target is either a script handle (exact) or a URL substring. The |
| 89 | 466 | * URL is checked against BOTH the current src and the pre-minify src, |
| @@ -138,8 +515,12 @@ | ||
| 138 | 515 | } |
| 139 | 516 | if ( self::is_excluded_script( (string) $handle, (string) $src ) ) { |
| 140 | 517 | return $tag; |
| 141 | 518 | } |
| 519 | + // The tag itself asked to be left alone. (#456) | |
| 520 | + if ( self::tag_opts_out( $tag ) ) { | |
| 521 | + return $tag; | |
| 522 | + } | |
| 142 | 523 | // Inline code elsewhere on the page reads this handle (or something |
| 143 | 524 | // it depends on). Inline blocks never defer, so deferring this one |
| 144 | 525 | // would run the consumer first. Defer only — delay is an opt-in |
| 145 | 526 | // target list, where the user has named the script deliberately. |
| @@ -187,11 +568,27 @@ | ||
| 187 | 568 | } |
| 188 | 569 | if ( self::is_excluded_script( (string) $handle, (string) $src ) ) { |
| 189 | 570 | return $tag; |
| 190 | 571 | } |
| 572 | + // The tag itself asked to be left alone. (#456) | |
| 573 | + if ( self::tag_opts_out( $tag ) ) { | |
| 574 | + return $tag; | |
| 575 | + } | |
| 191 | 576 | if ( ! self::is_delay_target( (string) $handle, (string) $src ) ) { |
| 192 | 577 | return $tag; |
| 193 | 578 | } |
| 579 | + // Inline code elsewhere on the page reads this handle (or something | |
| 580 | + // it depends on) — same registry walk defer uses. Delaying it runs | |
| 581 | + // the consumer at parse time against a global that arrives on first | |
| 582 | + // interaction: `wp_add_inline_script( 'jquery-ui-core', | |
| 583 | + // 'jQuery.uiBackCompat…', 'before' )` throws "jQuery is not defined" | |
| 584 | + // the moment jquery-core is delayed. A handle the user NAMED in | |
| 585 | + // delay_js_targets is still delayed — an explicit entry is the user | |
| 586 | + // saying they know the inline consumer is safe to break or absent. | |
| 587 | + if ( isset( self::inline_bound_handles()[ (string) $handle ] ) | |
| 588 | + && ! self::is_user_named_target( (string) $handle, (string) $src ) ) { | |
| 589 | + return $tag; | |
| 590 | + } | |
| 194 | 591 | // A non-executable type means this tag is data, or is being held by |
| 195 | 592 | // somebody else on purpose. The buffer pass has always checked this; |
| 196 | 593 | // the enqueue path did not, so a consent-blocked or JSON-carrying |
| 197 | 594 | // handle could still be rewritten here. (#274) |
| @@ -385,8 +782,13 @@ | ||
| 385 | 782 | if ( false !== stripos( $tag, 'data-xs-delay' ) || false !== stripos( $tag, 'data-xs-src' ) ) { |
| 386 | 783 | return $tag; |
| 387 | 784 | } |
| 388 | 785 | |
| 786 | + // The tag itself asked to be left alone. (#456) | |
| 787 | + if ( self::tag_opts_out( $tag ) ) { | |
| 788 | + return $tag; | |
| 789 | + } | |
| 790 | + | |
| 389 | 791 | // No src → inline code. The enqueue path owns those; a |
| 390 | 792 | // buffer rewrite here would have to reason about execution |
| 391 | 793 | // order it cannot see. |
| 392 | 794 | // `(?<![-\w])` not `\b` — see the note on the enqueue-path |
| @@ -441,8 +843,18 @@ | ||
| 441 | 843 | } |
| 442 | 844 | if ( ! self::is_delay_target( $tag_handle, $src ) ) { |
| 443 | 845 | return $tag; |
| 444 | 846 | } |
| 847 | + // Mirror of the enqueue-path guard: a handle that inline code | |
| 848 | + // reads stays eager unless the user named it. wp_scripts() | |
| 849 | + // is still populated at xspeed_cache_final_html time on a | |
| 850 | + // MISS, so the registry walk is consultable here too; an | |
| 851 | + // unrecoverable handle ('') simply never matches the set. | |
| 852 | + if ( '' !== $tag_handle | |
| 853 | + && isset( self::inline_bound_handles()[ $tag_handle ] ) | |
| 854 | + && ! self::is_user_named_target( $tag_handle, $src ) ) { | |
| 855 | + return $tag; | |
| 856 | + } | |
| 445 | 857 | |
| 446 | 858 | return (string) preg_replace( |
| 447 | 859 | '#(?<![-\w])src\s*=\s*(["\'][^"\']*["\'])#i', |
| 448 | 860 | 'data-xs-src=$1 data-xs-delay="1"', |
| @@ -500,8 +912,13 @@ | ||
| 500 | 912 | if ( '' === trim( $body ) ) { |
| 501 | 913 | return $whole; |
| 502 | 914 | } |
| 503 | 915 | |
| 916 | + // The tag itself asked to be left alone. (#456) | |
| 917 | + if ( self::tag_opts_out( $attrs ) ) { | |
| 918 | + return $whole; | |
| 919 | + } | |
| 920 | + | |
| 504 | 921 | // Our own replay bootstrap. Its body quotes the delay |
| 505 | 922 | // machinery's own strings, so a pathological user target |
| 506 | 923 | // fragment could match it — and a parked bootstrap means |
| 507 | 924 | // nothing on the page ever replays. |
| @@ -594,8 +1011,17 @@ | ||
| 594 | 1011 | events.forEach(function(e){window.removeEventListener(e,load,{passive:true,capture:true});}); |
| 595 | 1012 | var delayed=document.querySelectorAll('script[data-xs-delay]'); |
| 596 | 1013 | delayed.forEach(function(s){ |
| 597 | 1014 | var n=document.createElement('script'); |
| 1015 | + // A dynamically-created script is async by default, so replayed | |
| 1016 | + // EXTERNALS would race each other; async=false restores document | |
| 1017 | + // order among the externals. Narrower guarantee, stated plainly: | |
| 1018 | + // a replayed INLINE script still executes synchronously at its | |
| 1019 | + // replaceChild, i.e. possibly before an earlier external has | |
| 1020 | + // finished LOADING — so an inline consumer of a delayed external | |
| 1021 | + // is only safe when both were delayed by explicit user targeting | |
| 1022 | + // (the inline-bound guard keeps the implicit case eager). | |
| 1023 | + n.async=false; | |
| 598 | 1024 | // Nonce hiding: a connected element's nonce CONTENT attribute reads |
| 599 | 1025 | // as "", so copying it via the attribute loop would hand the clone |
| 600 | 1026 | // an empty nonce and a nonce-based CSP would block the replay. The |
| 601 | 1027 | // IDL property still carries the real value. |
| @@ -1248,8 +1674,19 @@ | ||
| 1248 | 1674 | private static function is_excluded_script( string $handle, string $src ): bool { |
| 1249 | 1675 | if ( in_array( $handle, self::ALWAYS_EXCLUDED_HANDLES, true ) ) { |
| 1250 | 1676 | return true; |
| 1251 | 1677 | } |
| 1678 | + // Never defer or delay our own scripts. The fold and RUM beacons | |
| 1679 | + // measure the FIRST paint — delayed to first interaction they | |
| 1680 | + // measure a scrolled page or nothing, so fold quorum never fills | |
| 1681 | + // and full CSS deferral never licenses. Found live: delay_js with | |
| 1682 | + // empty targets delayed the fold beacon itself, and the site sat | |
| 1683 | + // at zero fold reports for hours while its stylesheets stayed | |
| 1684 | + // render-blocking. Prefix, not a handle list, so a Pro module's | |
| 1685 | + // beacon added later cannot re-open the hole. | |
| 1686 | + if ( 0 === strpos( $handle, 'xspeed-' ) ) { | |
| 1687 | + return true; | |
| 1688 | + } | |
| 1252 | 1689 | $opts = self::opts(); |
| 1253 | 1690 | $excluded = is_array( $opts['defer_js_excluded'] ?? null ) ? $opts['defer_js_excluded'] : array(); |
| 1254 | 1691 | if ( empty( $excluded ) ) { |
| 1255 | 1692 | return false; |
| @@ -1293,8 +1730,31 @@ | ||
| 1293 | 1730 | } |
| 1294 | 1731 | return false; |
| 1295 | 1732 | } |
| 1296 | 1733 | |
| 1734 | + /** | |
| 1735 | + * Whether the user EXPLICITLY named this script in delay_js_targets. | |
| 1736 | + * | |
| 1737 | + * Unlike is_delay_target() this never treats an empty list as | |
| 1738 | + * everything and never falls back to the vendor list — it answers | |
| 1739 | + * only "did the user deliberately point at this handle/URL?", which | |
| 1740 | + * is what lets an explicit entry override the inline-bound guard. | |
| 1741 | + * | |
| 1742 | + * @param string $handle Script handle. | |
| 1743 | + * @param string $src Script URL. | |
| 1744 | + */ | |
| 1745 | + private static function is_user_named_target( string $handle, string $src ): bool { | |
| 1746 | + $opts = self::opts(); | |
| 1747 | + $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array(); | |
| 1748 | + foreach ( $targets as $needle ) { | |
| 1749 | + $needle = (string) $needle; | |
| 1750 | + if ( '' !== $needle && self::target_matches( $needle, $handle, $src ) ) { | |
| 1751 | + return true; | |
| 1752 | + } | |
| 1753 | + } | |
| 1754 | + return false; | |
| 1755 | + } | |
| 1756 | + | |
| 1297 | 1757 | private static function is_delay_target( string $handle, string $src ): bool { |
| 1298 | 1758 | $opts = self::opts(); |
| 1299 | 1759 | $targets = is_array( $opts['delay_js_targets'] ?? null ) ? $opts['delay_js_targets'] : array(); |
| 1300 | 1760 | $targets = array_filter( array_map( 'strval', $targets ), static fn( $t ) => '' !== $t ); |
| @@ -1432,8 +1892,9 @@ | ||
| 1432 | 1892 | self::$uploads_base = null; |
| 1433 | 1893 | self::$delay_bootstrap_printed = false; |
| 1434 | 1894 | self::$js_measured_layout = null; |
| 1435 | 1895 | self::$inline_bound_handles = null; |
| 1896 | + self::$pristine_tag = array(); | |
| 1436 | 1897 | } |
| 1437 | 1898 | |
| 1438 | 1899 | /** |
| 1439 | 1900 | * Per-request memo for inline_bound_handles(). Null = not resolved. |