Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiment: Add full page client-side navigation experiment setting #59707

Merged
merged 61 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6a1b29a
Add Gutenberg experiment option
SantosGuillamot Mar 5, 2024
a5b6dd0
Add config option and directives in PHP
SantosGuillamot Mar 5, 2024
cf8e72b
Load full CSN logic conditionally
SantosGuillamot Mar 5, 2024
8ddc122
Add `data-wp-interactive` root
SantosGuillamot Mar 5, 2024
32cd62c
Change variables names
SantosGuillamot Mar 5, 2024
cc25473
Register different scripts if the experiment is enabled
SantosGuillamot Mar 6, 2024
dffa452
Require experimental code once interactivity is loaded
SantosGuillamot Mar 6, 2024
7412f98
Change experiment namespace
SantosGuillamot Mar 6, 2024
f92355d
Move full-csn logic to interactivity-router
SantosGuillamot Mar 6, 2024
04b94a9
Add proper support for prefetch
SantosGuillamot Mar 6, 2024
ca90d0a
Adapt query loop
SantosGuillamot Mar 6, 2024
34e6b22
Fix modules error after csn
SantosGuillamot Mar 6, 2024
4f67b70
Add initial page to cache
SantosGuillamot Mar 6, 2024
592b842
WIP: Fix scripts loading after csn
SantosGuillamot Mar 7, 2024
1593483
Simplify code
SantosGuillamot Mar 7, 2024
afda3e6
Adapt query loop block
SantosGuillamot Mar 8, 2024
195e7e7
Fix full CSN when queryID is not defined
SantosGuillamot Mar 8, 2024
c8348ab
Remove preload logic
SantosGuillamot Mar 8, 2024
69a70d6
Change full csn conditional in query
SantosGuillamot Mar 8, 2024
9b2c853
Use only one app in the body
SantosGuillamot Mar 8, 2024
8f16b30
Use getRegionRootFragment and initialVdom
SantosGuillamot Mar 8, 2024
7f458b1
Adapt all query loop blocks
SantosGuillamot Mar 8, 2024
1d96c64
Add key to query loop block
SantosGuillamot Mar 8, 2024
63c81a6
Add `yield` to query block actions
SantosGuillamot Mar 8, 2024
652a2a2
Revert conditional scripts depending on the experiment
SantosGuillamot Mar 22, 2024
442248c
Register `interactivity-router-full-client-side-navigation` in the ex…
SantosGuillamot Mar 22, 2024
0f3bc44
Load router conditionally in query loop
SantosGuillamot Mar 22, 2024
62f6948
Scroll to anchor
SantosGuillamot Mar 22, 2024
a26bfa7
Remove unnecessary empty conditional
SantosGuillamot Mar 22, 2024
a5a7279
Fix back and forward buttons
SantosGuillamot Mar 22, 2024
f984294
Fix query loop
SantosGuillamot Mar 22, 2024
0f7a8db
Remove unnecessary conditional
SantosGuillamot Mar 22, 2024
cd7a112
Use full page client-side navigation naming
SantosGuillamot Mar 25, 2024
dd2b73e
Change comments
SantosGuillamot Mar 25, 2024
7eef1ce
Use render_block_data to change query attribute
SantosGuillamot Mar 25, 2024
f0fecee
Refactor JavaScript logic
SantosGuillamot Mar 25, 2024
7081358
Remove unused variable
SantosGuillamot Mar 26, 2024
1d00a3c
Revert changes in query block view.js file
SantosGuillamot Mar 26, 2024
a318179
Remove unnecessary export from interactivity
SantosGuillamot Mar 26, 2024
2138f0d
Move logic to the existing router
SantosGuillamot Mar 26, 2024
79eac03
Use vdom.get document.body
SantosGuillamot Mar 26, 2024
5dcaa1d
Remove nextTick function
SantosGuillamot Mar 27, 2024
fb5f911
Only call getElement when it is an event
SantosGuillamot Mar 27, 2024
afa7127
Allow instanceof URL in navigate
SantosGuillamot Mar 27, 2024
79768b6
Fix full page client-side navigation
SantosGuillamot Mar 27, 2024
181614e
Use `wp_enqueue_scripts` hook
SantosGuillamot Mar 27, 2024
36e0901
Clean PHP code and docs
SantosGuillamot Mar 27, 2024
455b8d7
Move internal dependencies after WordPress ones
SantosGuillamot Mar 27, 2024
a2c1a07
Add initial JSDocs to head helper functions
SantosGuillamot Mar 27, 2024
f7e5212
Allow URL instance in prefetch function
SantosGuillamot Mar 27, 2024
8f09d2b
Properly support prefetch
SantosGuillamot Mar 27, 2024
743926f
Fix JSDoc comments
SantosGuillamot Mar 27, 2024
44d0873
Add Promise to JSDoc
SantosGuillamot Apr 15, 2024
eeb6491
Specify experimental in query help message
SantosGuillamot Apr 16, 2024
46ce031
Wrap fullPage code in IS_GUTENBERG_PLUGIN check
SantosGuillamot Apr 16, 2024
57ef83c
Use static variable to add body directive once
SantosGuillamot Apr 16, 2024
a5b9e80
Wrap fetch in try/catch
SantosGuillamot Apr 16, 2024
4f9d728
Rename document variable to doc
SantosGuillamot Apr 16, 2024
d7e6ddb
Prevent client navigation in admin links
SantosGuillamot Apr 16, 2024
d72cd02
Add event listeners for navigate and prefetch in JS
SantosGuillamot Apr 17, 2024
745e675
Add check for anchor links of the same page
SantosGuillamot Apr 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/experimental/editor-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ function gutenberg_enable_experiments() {
if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) {
wp_add_inline_script( 'wp-block-library', 'window.__experimentalDisableTinymce = true', 'before' );
}
if ( gutenberg_is_experiment_enabled( 'gutenberg-full-page-client-side-navigation' ) ) {
wp_add_inline_script( 'wp-block-library', 'window.__experimentalFullPageClientSideNavigation = true', 'before' );
}
}

add_action( 'admin_init', 'gutenberg_enable_experiments' );
Expand Down
60 changes: 60 additions & 0 deletions lib/experimental/full-page-client-side-navigation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php
/**
* Registers full page client-side navigation option using the Interactivity API and adds the necessary directives.
*/

/**
* Enqueue the interactivity router script.
*/
function _gutenberg_enqueue_interactivity_router() {
// Set the navigation mode to full page client-side navigation.
wp_interactivity_config( 'core/router', array( 'navigationMode' => 'fullPage' ) );
wp_enqueue_script_module( '@wordpress/interactivity-router' );
}

add_action( 'wp_enqueue_scripts', '_gutenberg_enqueue_interactivity_router' );

/**
* Set enhancedPagination attribute for query loop when the experiment is enabled.
*
* @param array $parsed_block The parsed block.
*
* @return array The same parsed block with the modified attribute.
*/
function _gutenberg_add_enhanced_pagination_to_query_block( $parsed_block ) {
if ( 'core/query' !== $parsed_block['blockName'] ) {
return $parsed_block;
}

$parsed_block['attrs']['enhancedPagination'] = true;
return $parsed_block;
}

add_filter( 'render_block_data', '_gutenberg_add_enhanced_pagination_to_query_block' );

/**
* Add directives to all links.
*
* Note: This should probably be done per site, not by default when this option is enabled.
*
* @param array $content The block content.
*
* @return array The same block content with the directives needed.
*/
function _gutenberg_add_client_side_navigation_directives( $content ) {
$p = new WP_HTML_Tag_Processor( $content );
while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) {
if ( empty( $p->get_attribute( 'data-wp-on--click' ) ) ) {
$p->set_attribute( 'data-wp-on--click', 'core/router::actions.navigate' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it is worth, I think we should use event delegation for this. Something similar to this, but adding our checks (if the element already has a data-wp-on--click...): https://gist.github.com/devongovett/919dc0f06585bd88af053562fd7c41b7

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea. I guess it makes sense if we address that in a follow-up PR, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@felixarntz related to the discussion in #60574 regarding async event handlers, if event delegation were employed like this above Gist does, then this is another area where synchronous event handlers can negatively impact INP. It would be likely that analytics plugins would adopt similar event delegation rather than having to use the tag processor to inject their own click event directives on each link.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this experiment, I added this commit to use document.addEventListener instead of the previous directives. I also added a follow-up task to this list to explore this more deeply.

}
if ( empty( $p->get_attribute( 'data-wp-on--mouseenter' ) ) ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about mobile devices? Does mouseenter still work there somehow? Based on the event name I would think we need to also support a mobile friendly event.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK no, mouseenter does not work on mobile. Closest thing would be touchstart.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also a good example for where multiple actions should be able to be specified for a given event, as we've been discussing in #60574. There should not be a requirement that the data-wp-on--mouseenter attribute be empty. It should rather do something like:

$p->set_attribute( 'data-wp-on--mouseenter--core-router', 'core/router::actions.prefetch' );

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe let's wait until #60661 has landed, then we can fix these attributes to be unconditionally added, using a suffix for uniqueness.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a follow-up task to this list to explore and decide how to interact with mobile events.

$p->set_attribute( 'data-wp-on--mouseenter', 'core/router::actions.prefetch' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per @gziolo's comment on https://make.wordpress.org/core/2024/04/09/speculative-loading-in-wordpress/, if we eventually move forward with adopting the Speculation Rules API in WordPress core, we would need to consider whether or how this conflicts with it.

FWIW the Speculation Rules API is a web technology, and if it allows us to replace this manual implementation, that would probably be a good thing. That said, I'm not sure how it would behave here given that we don't do a "real" navigation when those links are clicked but client-side navigation. It might just work out of the box, but that would need to be tested.

I'm mostly commenting for visibility, not something we need to cater for at the moment, as it's going to be a while. But once this has landed in Gutenberg, we may actually want to try using it in combination with the https://wordpress.org/plugins/speculation-rules/ plugin to see what happens.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, speculation rules would most likely need to be disabled on sites with full page client-side navigation. This is because each link click would have a preventDefault and so the normal browser navigation would never happen. So the speculative loads would likely be wasted, especially the prerender ones. It may be that prefetch speculative loads might be able to be reused, but I'm unsure if the fetch() from the Interactivity API could reuse them.

Copy link
Member

@felixarntz felixarntz Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potentially we don't have to completely disable them. There may be ways to configure the rules so that any a tags with those specific Interactivity API attributes would be skipped.

This wouldn't be helpful if the entire page is using them, but for pages partially using this it would give the best of both worlds. Worth trying when we actually start experimenting with the integration.

Copy link
Member

@westonruter westonruter Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just put together a quick test of how Speculation Rules work with client-side navigation: https://speculation-rules-with-client-side-navigation.glitch.me/

In short, prefetch does work but prerender does not.

I see that clicking the "with prefetch" link results in (prefetch cache) appearing for the entry in the network log, whereas the "with prerender" link is the same as if there was no speculative load at all.

All this to say, it will be important for prefetch to be used exclusively on client-side navigation sites. Unfortunately this means they'll miss out on instant page loads that come with prerendering (including loading the subresources like images).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction: It seems prerendering will add the subresources to the cache, but the prerendered HTML is not served from the prefetch cache for the fetch().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction 2: It seems Glitch sending max-age=0 for the speculative prerender response prevented it from being served from cache, while this wasn't the case for the speculative prefetch response. I removed max-age=0 from that response, and now it seems speculative prerenders are served from cache for client-side navigations via fetch(): https://speculation-rules-with-client-side-navigation.glitch.me/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a follow-up task to this list to explore deeper how both projects interact after this experiment.

}
}
// Hack to add the necessary directives to the body tag.
// TODO: Find a proper way to add directives to the body tag.
return (string) $p . '<body data-wp-interactive="core/experimental" data-wp-context="{}">';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use a static variable here to add the <body> tag once.

Suggested change
return (string) $p . '<body data-wp-interactive="core/experimental" data-wp-context="{}">';
static $body_interactive_added;
if ( ! $body_interactive_added ) {
$body_interactive_added = true;
return (string) $p . '<body data-wp-interactive="core/experimental" data-wp-context="{}">';
}
return (string) $p;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That'd be important to ensure it gets added only once.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this injecting an additional <body> tag after the body has already been opened? Like this?

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <h1>
      Hello World!
    </h1>
    <body class="hey">
  </body>
</html>

Surprisingly, this actually works:

image

Maybe I shouldn't be surprised given HTML's loose parsing rules. But I can imagine other plugins that try to parse the HTML to, for example, apply various optimizations would get might confused when encountering multiple <body> tags.

Shouldn't the attributes rather get injected on the existing body tag instead with the HTML Tag Processor? I see this is not currently possible since the body tag is hard-coded in template-canvas.php. This brings up the question again of output buffering for the entire template (Core-43258). It's something I've been working on in the context of the Performance team's Optimization Detective plugin which output buffers the entire template and then uses HTML Tag Processor to do optimizations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added the check in this commit and I added a follow-up task in this list to explore how to do this properly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened PR to address this: #61212

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thank you for sharing @westonruter.

}

// TODO: Explore moving this to the server directive processing.
add_filter( 'render_block', '_gutenberg_add_client_side_navigation_directives' );
12 changes: 12 additions & 0 deletions lib/experiments-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ function gutenberg_initialize_experiments_settings() {
)
);

add_settings_field(
'gutenberg-full-page-client-side-navigation',
__( 'Enable full page client-side navigation', 'gutenberg' ),
'gutenberg_display_experiment_field',
'gutenberg-experiments',
'gutenberg_experiments_section',
array(
'label' => __( 'Enable full page client-side navigation using the Interactivity API', 'gutenberg' ),
'id' => 'gutenberg-full-page-client-side-navigation',
)
);

register_setting(
'gutenberg-experiments',
'gutenberg-experiments'
Expand Down
3 changes: 3 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/demo.php';
require __DIR__ . '/experiments-page.php';
require __DIR__ . '/interactivity-api.php';
if ( gutenberg_is_experiment_enabled( 'gutenberg-full-page-client-side-navigation' ) ) {
require __DIR__ . '/experimental/full-page-client-side-navigation.php';
}

// Copied package PHP files.
if ( is_dir( __DIR__ . '/../build/style-engine' ) ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ export default function EnhancedPaginationModal( {
useUnsupportedBlocks( clientId );

useEffect( () => {
if ( enhancedPagination && hasUnsupportedBlocks ) {
if (
enhancedPagination &&
hasUnsupportedBlocks &&
! window.__experimentalFullPageClientSideNavigation
) {
setAttributes( { enhancedPagination: false } );
setOpen( true );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ export default function EnhancedPaginationControl( {
clientId,
} ) {
const { hasUnsupportedBlocks } = useUnsupportedBlocks( clientId );
const fullPageClientSideNavigation =
window.__experimentalFullPageClientSideNavigation;

let help = __( 'Browsing between pages requires a full page reload.' );
if ( enhancedPagination ) {
if ( fullPageClientSideNavigation ) {
help = __( 'Full page client-side navigation enabled.' );
SantosGuillamot marked this conversation as resolved.
Show resolved Hide resolved
} else if ( enhancedPagination ) {
help = __(
"Browsing between pages won't require a full page reload, unless non-compatible blocks are detected."
);
Expand All @@ -32,8 +36,12 @@ export default function EnhancedPaginationControl( {
<ToggleControl
label={ __( 'Force page reload' ) }
help={ help }
checked={ ! enhancedPagination }
disabled={ hasUnsupportedBlocks }
checked={
! enhancedPagination && ! fullPageClientSideNavigation
}
disabled={
hasUnsupportedBlocks || fullPageClientSideNavigation
}
onChange={ ( value ) => {
setAttributes( {
enhancedPagination: ! value,
Expand Down
2 changes: 1 addition & 1 deletion packages/block-library/src/query/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ function render_block_core_query( $attributes, $content, $block ) {
// Add the necessary directives.
$p->set_attribute( 'data-wp-interactive', 'core/query' );
$p->set_attribute( 'data-wp-router-region', 'query-' . $attributes['queryId'] );
$p->set_attribute( 'data-wp-init', 'callbacks.setQueryRef' );
$p->set_attribute( 'data-wp-context', '{}' );
$p->set_attribute( 'data-wp-key', $attributes['queryId'] );
gziolo marked this conversation as resolved.
Show resolved Hide resolved
$content = $p->get_updated_html();
}
}
Expand Down
91 changes: 91 additions & 0 deletions packages/interactivity-router/src/head.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Helper to update only the necessary tags in the head.
*
* @async
* @param {Array} newHead The head elements of the new page.
*
*/
export const updateHead = async ( newHead ) => {
// Helper to get the tag id store in the cache.
const getTagId = ( tag ) => tag.id || tag.outerHTML;

// Map incoming head tags by their content.
const newHeadMap = new Map();
for ( const child of newHead ) {
newHeadMap.set( getTagId( child ), child );
}

const toRemove = [];

// Detect nodes that should be added or removed.
for ( const child of document.head.children ) {
const id = getTagId( child );
// Always remove styles and links as they might change.
if ( child.nodeName === 'LINK' || child.nodeName === 'STYLE' )
toRemove.push( child );
Comment on lines +23 to +25
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the stylesheet remains the same, I think it should be left as-is. Otherwise, removing a stylesheet and then adding it right back in will likely cause a performance problem.

Example: https://mousy-citrine-lotus.glitch.me/

Note there is a flash of unstyled content. It also causes layout and style recalculation:

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to be able to reuse DOM diffing from Preact here. (Not sure if that's possible.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a follow-up task to this list to explore this after this experiment is merged.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried using Preact at first, but if the scripts and styles change order or new elements appear in the head, Preact gets confused and deletes and re-adds everything.

I think it will be simpler and more stable if we control it ourselves.

else if ( newHeadMap.has( id ) ) newHeadMap.delete( id );
else if ( child.nodeName !== 'SCRIPT' && child.nodeName !== 'META' )
toRemove.push( child );
}

// Prepare new assets.
const toAppend = [ ...newHeadMap.values() ];

// Apply the changes.
toRemove.forEach( ( n ) => n.remove() );
document.head.append( ...toAppend );
};

/**
* Fetches and processes head assets (stylesheets and scripts) from a specified document.
*
* @async
* @param {Document} document The document from which to fetch head assets. It should support standard DOM querying methods.
* @param {Map} headElements A map of head elements to modify tracking the URLs of already processed assets to avoid duplicates.
*
* @return {HTMLElement[]} Returns an array of HTML elements representing the head assets.
SantosGuillamot marked this conversation as resolved.
Show resolved Hide resolved
*/
export const fetchHeadAssets = async ( document, headElements ) => {
SantosGuillamot marked this conversation as resolved.
Show resolved Hide resolved
const headTags = [];
const assets = [
{
tagName: 'style',
selector: 'link[rel=stylesheet]',
attribute: 'href',
},
{ tagName: 'script', selector: 'script[src]', attribute: 'src' },
];
for ( const asset of assets ) {
const { tagName, selector, attribute } = asset;
const tags = document.querySelectorAll( selector );

// Use Promise.all to wait for fetch to complete
await Promise.all(
Array.from( tags ).map( async ( tag ) => {
const attributeValue = tag.getAttribute( attribute );
if ( ! headElements.has( attributeValue ) ) {
const response = await fetch( attributeValue );
SantosGuillamot marked this conversation as resolved.
Show resolved Hide resolved
const text = await response.text();
headElements.set( attributeValue, {
tag,
text,
} );
}

const headElement = headElements.get( attributeValue );
const element = document.createElement( tagName );
element.innerText = headElement.text;
Copy link
Contributor

@michalczaplinski michalczaplinski Apr 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For styles we should use element.textContent because innerText might cause a
reflow

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it as a follow-up to explore after this experiment.

for ( const attr of headElement.tag.attributes ) {
element.setAttribute( attr.name, attr.value );
}
headTags.push( element );
} )
);
}

return [
document.querySelector( 'title' ),
...document.querySelectorAll( 'style' ),
...headTags,
];
};
Loading
Loading