Skip to content

Commit

Permalink
Interactivity API: Improvements to the experimental full-page navigat…
Browse files Browse the repository at this point in the history
…ion (#64067)

* fix: Add initial data population in interactivity router

* chore: Update element.innerText to element.textContent in head.ts file

* feat: Exclude src and href attributes when copying head element attributes

* chore: Populate initial data in interactivity router

* chore: Move Populate initial data in interactivity router

* Wait for `load` event of script element before returning from `fetchHeadAssets()` function

* feat: Update head tags to improve prefetching of scripts and stylesheets

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.

* Do not load interactivity script modules in development mode when full page navigation is enabled

* Format interactivity-api.php

* Update interactivity script module registration to use version from asset files

- Added logic to retrieve version information from `index.min.asset.php` and `router.min.asset.php` files.
- Updated `wp_register_script_module` calls to use the retrieved version instead of the default version when full-page navigation is not enabled.

* empty commit

* Rename populateInitialData to populateServerData

* remove populateServerData

* try: remove the webpack comment

* try: remove the await import()

* bring back the async import

* Move headElements to head.ts

* Revert "try: remove the webpack comment"

This reverts commit 62e527e.

* default_version => router_version

* Remove the changes to interactivity-api.php

* Make `renderRegions` async

* Update TS type of the stylesheets variable

Co-authored-by: Jon Surrell <[email protected]>

* Replace Array.from() with direct forEach() on NodeList in head.ts:

* Check if href is null

* Clarify why we only prefetch script modules

* Add changelog

---------

Co-authored-by: michalczaplinski <[email protected]>
Co-authored-by: DAreRodz <[email protected]>
Co-authored-by: sirreal <[email protected]>
Co-authored-by: gziolo <[email protected]>
Co-authored-by: luisherranz <[email protected]>
Co-authored-by: westonruter <[email protected]>
  • Loading branch information
7 people authored Oct 8, 2024
1 parent 1b8751b commit db22c1b
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 72 deletions.
8 changes: 8 additions & 0 deletions packages/interactivity-router/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

### Enhancements

- Improvements to the experimental full-page navigation ([#64067](https://github.com/WordPress/gutenberg/pull/64067)):
- Remove the `src` attributes from prefetched script tags.
- Use `.textContent` instead of `.innerText` to set `<script>` contents.
- Use `populateInitialData()` with state from the server.
- Wait for the `load` event of the script element before evaluating it.

## 2.9.0 (2024-10-03)

## 2.8.0 (2024-09-19)
Expand Down
114 changes: 68 additions & 46 deletions packages/interactivity-router/src/head.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
/**
* The cache of prefetched stylesheets and scripts.
*/
export const headElements = new Map<
string,
{ tag: HTMLElement; text?: string }
>();

/**
* Helper to update only the necessary tags in the head.
*
Expand Down Expand Up @@ -29,6 +37,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,60 +57,66 @@ 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 headElement = headElements.get( attributeValue );
const element = doc.createElement( tagName );
element.innerText = headElement.text;
for ( const attr of headElement.tag.attributes ) {
element.setAttribute( attr.name, attr.value );
// We only want to fetch module scripts because regular scripts (without
// `async` or `defer` attributes) can depend on the execution of other scripts.
// Scripts found in the head are blocking and must be executed in order.
const scripts = doc.querySelectorAll< HTMLScriptElement >(
'script[type="module"][src]'
);

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 stylesheets = doc.querySelectorAll< HTMLLinkElement >(
'link[rel=stylesheet]'
);

await Promise.all(
Array.from( stylesheets ).map( async ( tag ) => {
const href = tag.getAttribute( 'href' );
if ( ! href ) {
return;
}

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;

headTags.push( styleElement );
} )
);

return [
doc.querySelector( 'title' ),
Expand Down
56 changes: 30 additions & 26 deletions packages/interactivity-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { store, privateApis, getConfig } from '@wordpress/interactivity';
/**
* Internal dependencies
*/
import { fetchHeadAssets, updateHead } from './head';
import { fetchHeadAssets, updateHead, headElements } from './head';

const {
directivePrefix,
Expand Down Expand Up @@ -54,7 +54,6 @@ 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 } >();

// 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 +86,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 @@ -108,31 +107,34 @@ const regionsToVdom: RegionsToVdom = async ( dom, { vdom } = {} ) => {
};

// Render all interactive regions contained in the given page.
const renderRegions = ( page: Page ) => {
batch( () => {
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 );
const fragment = getRegionRootFragment( document.body );
const renderRegions = async ( page: Page ) => {
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.
await updateHead( page.head );
const fragment = getRegionRootFragment( document.body );
batch( () => {
populateServerData( page.initialData );
render( page.regions.body, fragment );
}
} );
}
if ( navigationMode === 'regionBased' ) {
}
if ( navigationMode === 'regionBased' ) {
const attrName = `data-${ directivePrefix }-router-region`;
batch( () => {
populateServerData( page.initialData );
const attrName = `data-${ directivePrefix }-router-region`;
document
.querySelectorAll( `[${ attrName }]` )
.forEach( ( region ) => {
const id = region.getAttribute( attrName );
const fragment = getRegionRootFragment( region );
render( page.regions[ id ], fragment );
} );
}
if ( page.title ) {
document.title = page.title;
}
} );
} );
}
if ( page.title ) {
document.title = page.title;
}
};

/**
Expand All @@ -156,7 +158,7 @@ window.addEventListener( 'popstate', async () => {
const pagePath = getPagePath( window.location.href ); // Remove hash.
const page = pages.has( pagePath ) && ( await pages.get( pagePath ) );
if ( page ) {
renderRegions( page );
await renderRegions( page );
// Update the URL in the state.
state.url = window.location.href;
} else {
Expand All @@ -170,13 +172,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 db22c1b

Please sign in to comment.