Skip to content

Commit

Permalink
feat: Update head tags to improve prefetching of scripts and stylesheets
Browse files Browse the repository at this point in the history
This commit modifies the `updateHead` function in `head.ts` to improve support for lazy loading of scripts and stylesheets. It preloades the script modules using `modulepreload`, imports the necessary scripts using dynamic imports and adds the `preload` link elements for stylesheets.
  • Loading branch information
michalczaplinski committed Aug 21, 2024
1 parent bbd5cb9 commit d7f9ba2
Show file tree
Hide file tree
Showing 2 changed files with 70 additions and 64 deletions.
107 changes: 54 additions & 53 deletions packages/interactivity-router/src/head.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* Internal dependencies
*/
import { headElements } from '.';

/**
* Helper to update only the necessary tags in the head.
*
Expand Down Expand Up @@ -29,6 +34,14 @@ export const updateHead = async ( newHead: HTMLHeadElement[] ) => {
}
}

await Promise.all(
[ ...headElements.entries() ]
.filter( ( [ , { tag } ] ) => tag.nodeName === 'SCRIPT' )
.map( async ( [ url ] ) => {
await import( /* webpackIgnore: true */ url );
} )
);

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

Expand All @@ -41,71 +54,59 @@ export const updateHead = async ( newHead: HTMLHeadElement[] ) => {
* Fetches and processes head assets (stylesheets and scripts) from a specified document.
*
* @async
* @param doc The document from which to fetch head assets. It should support standard DOM querying methods.
* @param headElements A map of head elements to modify tracking the URLs of already processed assets to avoid duplicates.
* @param headElements.tag
* @param headElements.text
* @param doc The document from which to fetch head assets. It should support standard DOM querying methods.
*
* @return Returns an array of HTML elements representing the head assets.
*/
export const fetchHeadAssets = async (
doc: Document,
headElements: Map< string, { tag: HTMLElement; text: string } >
doc: Document
): Promise< HTMLElement[] > => {
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 = doc.querySelectorAll<
HTMLScriptElement | HTMLStyleElement
>( 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 ) ) {
try {
const response = await fetch( attributeValue );
const text = await response.text();
headElements.set( attributeValue, {
tag,
text,
} );
} catch ( e ) {
// eslint-disable-next-line no-console
console.error( e );
}
}
const scripts = doc.querySelectorAll< HTMLScriptElement >(
'script[type="module"][src]'
);

Array.from( scripts ).forEach( ( script ) => {
const src = script.getAttribute( 'src' );
if ( ! headElements.has( src ) ) {
// add the <link> elements to prefetch the module scripts
const link = doc.createElement( 'link' );
link.rel = 'modulepreload';
link.href = src;
document.head.append( link );
headElements.set( src, { tag: script } );
}
} );

const headElement = headElements.get( attributeValue );
const element = doc.createElement( tagName );
element.textContent = headElement.text;
const stylesheets = doc.querySelectorAll< HTMLScriptElement >(
'link[rel=stylesheet]'
);

for ( const attr of headElement.tag.attributes ) {
// don't copy the src or href attribute
if ( attr.name !== 'src' && attr.name !== 'href' ) {
element.setAttribute( attr.name, attr.value );
}
await Promise.all(
Array.from( stylesheets ).map( async ( tag ) => {
const href = tag.getAttribute( 'href' );
if ( ! headElements.has( href ) ) {
try {
const response = await fetch( href );
const text = await response.text();
headElements.set( href, {
tag,
text,
} );
} catch ( e ) {
// eslint-disable-next-line no-console
console.error( e );
}
}

headTags.push( element );
const headElement = headElements.get( href );
const styleElement = doc.createElement( 'style' );
styleElement.textContent = headElement.text;

// wait for the `load` event to fire before appending the element
return new Promise( ( resolve, reject ) => {
element.onload = resolve;
element.onerror = reject;
} );
} )
);
}
headTags.push( styleElement );
} )
);

return [
doc.querySelector( 'title' ),
Expand Down
27 changes: 16 additions & 11 deletions packages/interactivity-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ const navigationMode: 'regionBased' | 'fullPage' =

// The cache of visited and prefetched pages, stylesheets and scripts.
const pages = new Map< string, Promise< Page | false > >();
const headElements = new Map< string, { tag: HTMLElement; text: string } >();
export const headElements = new Map<
string,
{ tag: HTMLElement; text?: string }
>();

// Helper to remove domain and hash from the URL. We are only interesting in
// caching the path and the query.
Expand Down Expand Up @@ -87,7 +90,7 @@ const regionsToVdom: RegionsToVdom = async ( dom, { vdom } = {} ) => {
let head: HTMLElement[];
if ( globalThis.IS_GUTENBERG_PLUGIN ) {
if ( navigationMode === 'fullPage' ) {
head = await fetchHeadAssets( dom, headElements );
head = await fetchHeadAssets( dom );
regions.body = vdom
? vdom.get( document.body )
: toVdom( dom.body );
Expand All @@ -109,12 +112,12 @@ const regionsToVdom: RegionsToVdom = async ( dom, { vdom } = {} ) => {

// Render all interactive regions contained in the given page.
const renderRegions = ( page: Page ) => {
batch( () => {
batch( async () => {
populateInitialData( page.initialData );
if ( globalThis.IS_GUTENBERG_PLUGIN ) {
if ( navigationMode === 'fullPage' ) {
// Once this code is tested and more mature, the head should be updated for region based navigation as well.
updateHead( page.head );
await updateHead( page.head );
const fragment = getRegionRootFragment( document.body );
render( page.regions.body, fragment );
}
Expand Down Expand Up @@ -170,13 +173,15 @@ window.addEventListener( 'popstate', async () => {
if ( globalThis.IS_GUTENBERG_PLUGIN ) {
if ( navigationMode === 'fullPage' ) {
// Cache the scripts. Has to be called before fetching the assets.
[].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => {
headElements.set( script.getAttribute( 'src' ), {
tag: script,
text: script.textContent,
} );
} );
await fetchHeadAssets( document, headElements );
[].map.call(
document.querySelectorAll( 'script[type="module"][src]' ),
( script ) => {
headElements.set( script.getAttribute( 'src' ), {
tag: script,
} );
}
);
await fetchHeadAssets( document );
}
}
pages.set(
Expand Down

0 comments on commit d7f9ba2

Please sign in to comment.