PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 30 releases
← All changes | includes/class-minify-filters.php +461 -0 1.3.01.3.4 View file →
@@ -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.