Skip to content

Commit

Permalink
Interactivity API : Refactor interactivity-router to TS (#61730)
Browse files Browse the repository at this point in the history
* Migrate interactivity-router to TS

* Migrate head.js to TypeScript

* chore: Update headElements type in fetchHeadAssets function

* chore: Update getTagId function in head.ts

* Removed jsdocs types

---------

Co-authored-by: michalczaplinski <czapla@git.wordpress.org>
Co-authored-by: cbravobernal <cbravobernal@git.wordpress.org>
  • Loading branch information
3 people committed May 20, 2024
1 parent a8ee5a7 commit 981309c
Show file tree
Hide file tree
Showing 3 changed files with 97 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@
* Helper to update only the necessary tags in the head.
*
* @async
* @param {Array} newHead The head elements of the new page.
* @param newHead The head elements of the new page.
*/
export const updateHead = async ( newHead ) => {
export const updateHead = async ( newHead: HTMLHeadElement[] ) => {
// Helper to get the tag id store in the cache.
const getTagId = ( tag ) => tag.id || tag.outerHTML;
const getTagId = ( tag: Element ) => tag.id || tag.outerHTML;

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

const toRemove = [];
const toRemove: Element[] = [];

// Detect nodes that should be added or removed.
for ( const child of document.head.children ) {
Expand All @@ -41,12 +41,17 @@ export const updateHead = async ( newHead ) => {
* Fetches and processes head assets (stylesheets and scripts) from a specified document.
*
* @async
* @param {Document} doc 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.
* @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
*
* @return {Promise<HTMLElement[]>} Returns an array of HTML elements representing the head assets.
* @return Returns an array of HTML elements representing the head assets.
*/
export const fetchHeadAssets = async ( doc, headElements ) => {
export const fetchHeadAssets = async (
doc: Document,
headElements: Map< string, { tag: HTMLElement; text: string } >
): Promise< HTMLElement[] > => {
const headTags = [];
const assets = [
{
Expand All @@ -58,7 +63,9 @@ export const fetchHeadAssets = async ( doc, headElements ) => {
];
for ( const asset of assets ) {
const { tagName, selector, attribute } = asset;
const tags = doc.querySelectorAll( selector );
const tags = doc.querySelectorAll<
HTMLScriptElement | HTMLStyleElement
>( selector );

// Use Promise.all to wait for fetch to complete
await Promise.all(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,50 @@ const {
'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.'
);

interface NavigateOptions {
force?: boolean;
html?: string;
replace?: boolean;
timeout?: number;
loadingAnimation?: boolean;
screenReaderAnnouncement?: boolean;
}

interface PrefetchOptions {
force?: boolean;
html?: string;
}

interface VdomParams {
vdom?: typeof initialVdom;
}

interface Page {
regions: Record< string, any >;
head: HTMLHeadElement[];
title: string;
initialData: any;
}

type RegionsToVdom = ( dom: Document, params?: VdomParams ) => Promise< Page >;

// Check if the navigation mode is full page or region based.
const navigationMode =
const navigationMode: 'regionBased' | 'fullPage' =
getConfig( 'core/router' ).navigationMode ?? 'regionBased';

// The cache of visited and prefetched pages, stylesheets and scripts.
const pages = new Map();
const headElements = new Map();
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.
const getPagePath = ( url ) => {
const u = new URL( url, window.location );
const getPagePath = ( url: string ) => {
const u = new URL( url, window.location.href );
return u.pathname + u.search;
};

// Fetch a new page and convert it to a static virtual DOM.
const fetchPage = async ( url, { html } ) => {
const fetchPage = async ( url: string, { html }: { html: string } ) => {
try {
if ( ! html ) {
const res = await window.fetch( url );
Expand All @@ -55,9 +82,10 @@ const fetchPage = async ( url, { html } ) => {

// Return an object with VDOM trees of those HTML regions marked with a
// `router-region` directive.
const regionsToVdom = async ( dom, { vdom } = {} ) => {
const regions = {};
let head;
const regionsToVdom: RegionsToVdom = async ( dom, { vdom } = {} ) => {
const regions = { body: undefined };
let head: HTMLElement[];
// @ts-ignore
if ( process.env.IS_GUTENBERG_PLUGIN ) {
if ( navigationMode === 'fullPage' ) {
head = await fetchHeadAssets( dom, headElements );
Expand All @@ -81,8 +109,9 @@ const regionsToVdom = async ( dom, { vdom } = {} ) => {
};

// Render all interactive regions contained in the given page.
const renderRegions = ( page ) => {
const renderRegions = ( page: Page ) => {
batch( () => {
// @ts-ignore
if ( process.env.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.
Expand Down Expand Up @@ -115,18 +144,18 @@ const renderRegions = ( page ) => {
* potential feedback indicating that the navigation has finished while the new
* page is being loaded.
*
* @param {string} href The page href.
* @return {Promise} Promise that never resolves.
* @param href The page href.
* @return Promise that never resolves.
*/
const forcePageReload = ( href ) => {
const forcePageReload = ( href: string ) => {
window.location.assign( href );
return new Promise( () => {} );
};

// Listen to the back and forward buttons and restore the page if it's in the
// cache.
window.addEventListener( 'popstate', async () => {
const pagePath = getPagePath( window.location ); // Remove hash.
const pagePath = getPagePath( window.location.href ); // Remove hash.
const page = pages.has( pagePath ) && ( await pages.get( pagePath ) );
if ( page ) {
renderRegions( page );
Expand All @@ -138,7 +167,9 @@ window.addEventListener( 'popstate', async () => {
} );

// Initialize the router and cache the initial page using the initial vDOM.
// Once this code is tested and more mature, the head should be updated for region based navigation as well.
// Once this code is tested and more mature, the head should be updated for
// region based navigation as well.
// @ts-ignore
if ( process.env.IS_GUTENBERG_PLUGIN ) {
if ( navigationMode === 'fullPage' ) {
// Cache the scripts. Has to be called before fetching the assets.
Expand All @@ -152,12 +183,12 @@ if ( process.env.IS_GUTENBERG_PLUGIN ) {
}
}
pages.set(
getPagePath( window.location ),
getPagePath( window.location.href ),
Promise.resolve( regionsToVdom( document, { vdom: initialVdom } ) )
);

// Check if the link is valid for client-side navigation.
const isValidLink = ( ref ) =>
const isValidLink = ( ref: HTMLAnchorElement ) =>
ref &&
ref instanceof window.HTMLAnchorElement &&
ref.href &&
Expand All @@ -169,7 +200,7 @@ const isValidLink = ( ref ) =>
! new URL( ref.href ).searchParams.has( '_wpnonce' );

// Check if the event is valid for client-side navigation.
const isValidEvent = ( event ) =>
const isValidEvent = ( event: MouseEvent ) =>
event &&
event.button === 0 && // Left clicks only.
! event.metaKey && // Open in new tab (Mac).
Expand All @@ -187,7 +218,11 @@ export const { state, actions } = store( 'core/router', {
navigation: {
hasStarted: false,
hasFinished: false,
texts: {},
texts: {
loading: '',
loaded: '',
},
message: '',
},
},
actions: {
Expand All @@ -198,18 +233,18 @@ export const { state, actions } = store( 'core/router', {
* needed, and updates any interactive regions whose contents have
* changed. It also creates a new entry in the browser session history.
*
* @param {string} href The page href.
* @param {Object} [options] Options object.
* @param {boolean} [options.force] If true, it forces re-fetching the URL.
* @param {string} [options.html] HTML string to be used instead of fetching the requested URL.
* @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history.
* @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000.
* @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`.
* @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`.
* @param href The page href.
* @param [options] Options object.
* @param [options.force] If true, it forces re-fetching the URL.
* @param [options.html] HTML string to be used instead of fetching the requested URL.
* @param [options.replace] If true, it replaces the current entry in the browser session history.
* @param [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000.
* @param [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`.
* @param [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`.
*
* @return {Promise} Promise that resolves once the navigation is completed or aborted.
* @return Promise that resolves once the navigation is completed or aborted.
*/
*navigate( href, options = {} ) {
*navigate( href: string, options: NavigateOptions = {} ) {
const { clientNavigationDisabled } = getConfig();
if ( clientNavigationDisabled ) {
yield forcePageReload( href );
Expand All @@ -228,7 +263,7 @@ export const { state, actions } = store( 'core/router', {

// Create a promise that resolves when the specified timeout ends.
// The timeout value is 10 seconds by default.
const timeoutPromise = new Promise( ( resolve ) =>
const timeoutPromise = new Promise< void >( ( resolve ) =>
setTimeout( resolve, timeout )
);

Expand Down Expand Up @@ -294,7 +329,7 @@ export const { state, actions } = store( 'core/router', {
}

// Scroll to the anchor if exits in the link.
const { hash } = new URL( href, window.location );
const { hash } = new URL( href, window.location.href );
if ( hash ) {
document.querySelector( hash )?.scrollIntoView();
}
Expand All @@ -309,33 +344,37 @@ export const { state, actions } = store( 'core/router', {
* The function normalizes the URL and stores internally the fetch
* promise, to avoid triggering a second fetch for an ongoing request.
*
* @param {string} url The page URL.
* @param {Object} [options] Options object.
* @param {boolean} [options.force] Force fetching the URL again.
* @param {string} [options.html] HTML string to be used instead of fetching the requested URL.
* @param url The page URL.
* @param [options] Options object.
* @param [options.force] Force fetching the URL again.
* @param [options.html] HTML string to be used instead of fetching the requested URL.
*/
prefetch( url, options = {} ) {
prefetch( url: string, options: PrefetchOptions = {} ) {
const { clientNavigationDisabled } = getConfig();
if ( clientNavigationDisabled ) {
return;
}

const pagePath = getPagePath( url );
if ( options.force || ! pages.has( pagePath ) ) {
pages.set( pagePath, fetchPage( pagePath, options ) );
pages.set(
pagePath,
fetchPage( pagePath, { html: options.html } )
);
}
},
},
} );

// Add click and prefetch to all links.
// @ts-ignore
if ( process.env.IS_GUTENBERG_PLUGIN ) {
if ( navigationMode === 'fullPage' ) {
// Navigate on click.
document.addEventListener(
'click',
function ( event ) {
const ref = event.target.closest( 'a' );
const ref = ( event.target as Element ).closest( 'a' );
if ( isValidLink( ref ) && isValidEvent( event ) ) {
event.preventDefault();
actions.navigate( ref.href );
Expand All @@ -347,8 +386,8 @@ if ( process.env.IS_GUTENBERG_PLUGIN ) {
document.addEventListener(
'mouseenter',
function ( event ) {
if ( event.target?.nodeName === 'A' ) {
const ref = event.target.closest( 'a' );
if ( ( event.target as Element )?.nodeName === 'A' ) {
const ref = ( event.target as Element ).closest( 'a' );
if ( isValidLink( ref ) && isValidEvent( event ) ) {
actions.prefetch( ref.href );
}
Expand Down
2 changes: 1 addition & 1 deletion packages/interactivity/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ const handlers = {
* @param namespace Store's namespace from which to retrieve the config.
* @return Defined config for the given namespace.
*/
export const getConfig = ( namespace: string ) =>
export const getConfig = ( namespace?: string ) =>
storeConfigs.get( namespace || getNamespace() ) || {};

interface StoreOptions {
Expand Down

0 comments on commit 981309c

Please sign in to comment.