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

Interactivity API : Refactor interactivity-router to TS #61730

Merged
merged 5 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
* @async
* @param {Array} 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 {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.
Copy link
Contributor

Choose a reason for hiding this comment

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

In Interactivity API package we are removing the type from the jsdoc to only leave it in TS.

* @param headElements.tag
* @param headElements.text
*
* @return {Promise<HTMLElement[]>} 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 );
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The URL() constructor automatically converts window.location to string using window.location.toString(). However, TS is not happy about it so I explicitly pass 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 ) {
Comment on lines +88 to 89
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 had to put a couple of @ts-ignore directives so that TS is happy with process.env.

Hopefully once #61486 is merged we can call globalThis.IS_GUTENBERG_PLUGIN and remove the @ts-ignores.

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 @@ -126,7 +155,7 @@ const forcePageReload = ( href ) => {
// 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 @@ -209,7 +244,7 @@ export const { state, actions } = store( 'core/router', {
*
* @return {Promise} 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 @@ -314,28 +349,32 @@ export const { state, actions } = store( 'core/router', {
* @param {boolean} [options.force] Force fetching the URL again.
* @param {string} [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 } )
Copy link
Contributor Author

@michalczaplinski michalczaplinski May 16, 2024

Choose a reason for hiding this comment

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

fetchPage() signature is:

const fetchPage = async ( url: string, { html }: { html: string } )

TS is not happy if we pass the whole options object which contains additional properties.

);
}
},
},
} );

// 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
Loading