@wordpress/interactivity-router
Version:
Package that exposes state and actions from the `core/router` store, part of the Interactivity API.
624 lines (555 loc) • 18.5 kB
text/typescript
/**
* WordPress dependencies
*/
import { store, privateApis, getConfig } from '@wordpress/interactivity';
/**
* Internal dependencies
*/
import { preloadStyles, applyStyles, type StyleElement } from './assets/styles';
import {
preloadScriptModules,
importScriptModules,
markScriptModuleAsResolved,
type ScriptModuleLoad,
} from './assets/script-modules';
const {
getRegionRootFragment,
initialVdomPromise,
toVdom,
render,
parseServerData,
populateServerData,
batch,
routerRegions,
h: createElement,
navigationSignal,
sessionId,
warn,
} = privateApis(
'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.'
);
const regionAttr = `data-wp-router-region`;
const interactiveAttr = `data-wp-interactive`;
const regionsSelector = `[${ interactiveAttr }][${ regionAttr }], [${ interactiveAttr }] [${ interactiveAttr }][${ regionAttr }]`;
export interface NavigateOptions {
force?: boolean;
html?: string;
replace?: boolean;
timeout?: number;
loadingAnimation?: boolean;
screenReaderAnnouncement?: boolean;
}
export interface PrefetchOptions {
force?: boolean;
html?: string;
}
interface VdomParams {
vdom?: WeakMap< Element, any >;
}
interface Page {
url: string;
regions: Record< string, any >;
regionsToAttach: Record< string, string >;
styles: StyleElement[];
scriptModules: ScriptModuleLoad[];
title: string;
initialData: any;
}
type PreparePage = (
url: string,
dom: Document,
params?: VdomParams
) => Promise< Page >;
// The cache of visited and prefetched pages, stylesheets and scripts.
const pages = new Map< string, Promise< Page | false > >();
// Helper to remove domain and hash from the URL. We are only interesting in
// caching the path and the query.
const getPagePath = ( url: string ) => {
const u = new URL( url, window.location.href );
return u.pathname + u.search;
};
/**
* Parses the given region's directive.
*
* @param region Region element.
* @return Data contained in the region directive value.
*/
const parseRegionAttribute = ( region: Element ) => {
const value = region.getAttribute( regionAttr );
try {
const { id, attachTo } = JSON.parse( value );
return { id, attachTo };
} catch {
return { id: value };
}
};
/**
* Clones the content of the router region vDOM passed as argument.
*
* The function creates a new VNode instance removing all priority levels up to
* the one containing the router-region directive, which should have evaluated
* in advance.
*
* @param vdom A router region's VNode.
* @return The VNode for the passed router region's content.
*/
const cloneRouterRegionContent = ( vdom: any ) => {
if ( ! vdom ) {
return vdom;
}
const allPriorityLevels: string[][] = vdom.props.priorityLevels;
const routerRegionLevel = allPriorityLevels.findIndex( ( level ) =>
level.includes( 'router-region' )
);
const priorityLevels =
routerRegionLevel !== -1
? allPriorityLevels.slice( routerRegionLevel + 1 )
: allPriorityLevels;
return priorityLevels.length > 0
? createElement( vdom.type, {
...vdom.props,
priorityLevels,
} )
: vdom.props.element;
};
/**
* IDs of router regions with an `attachTo` property pointing to the same parent
* element.
*/
const regionsToAttachByParent = new WeakMap< Element, string[] >();
/**
* Map of root fragments by parent element, used to render router regions with
* the `attachTo` property. Those elements with the same parent are rendered
* together in the corresponding root fragment.
*/
const rootFragmentsByParent = new WeakMap< Element, any >();
/**
* Set of router regions using the `attachTo` property that are present in the
* initial page.
*
* These regions should be treated as regular regions without the `attachTo`
* attribute as they don't need to be appended; they are already in the HTML.
*/
const initialRegionsToAttach = new Set< string >();
/**
* Fetches and prepares a page from a given URL.
*
* @param url The URL of the page to fetch.
* @param options Options for the fetch operation.
* @param options.html Optional HTML content. If provided, the function will use
* this instead of fetching from the URL.
* @return A Promise that resolves to the prepared page, or false if
* there was an error during fetching or preparation.
*/
const fetchPage = async ( url: string, { html }: { html: string } ) => {
try {
if ( ! html ) {
const res = await window.fetch( url );
if ( res.status !== 200 ) {
return false;
}
html = await res.text();
}
const dom = new window.DOMParser().parseFromString( html, 'text/html' );
return await preparePage( url, dom );
} catch {
return false;
}
};
/**
* Processes a DOM document to extract router regions and related resources.
*
* This function analyzes the provided DOM document and creates a virtual DOM
* representation of all HTML regions marked with a `router-region` directive.
* It also extracts and preloads associated styles and scripts to prepare for
* rendering the page.
*
* @param url The URL associated with the page, used for asset
* loading and caching.
* @param dom The DOM document to process.
* @param vdomParams Optional parameters for virtual DOM processing.
* @param vdomParams.vdom An optional existing virtual DOM cache to check for
* regions. If a region exists in this cache, it will be
* reused instead of creating a new vDOM representation.
* @return A Promise that resolves to a {@link Page} object
* containing the virtual DOM for all router regions,
* preloaded styles and scripts, page title, and initial
* server-rendered data.
*/
const preparePage: PreparePage = async ( url, dom, { vdom } = {} ) => {
// Remove all noscript elements as they're irrelevant when request is served via router.
// This prevents browsers from extracting styles from noscript tags.
dom.querySelectorAll( 'noscript' ).forEach( ( el ) => el.remove() );
const regions = {};
const regionsToAttach = {};
dom.querySelectorAll( regionsSelector ).forEach( ( region ) => {
const { id, attachTo } = parseRegionAttribute( region );
if ( region.parentElement.closest( `[${ regionAttr }]` ) ) {
regions[ id ] = undefined;
} else {
regions[ id ] = vdom?.has( region )
? vdom.get( region )
: toVdom( region );
}
if ( attachTo && ! initialRegionsToAttach.has( id ) ) {
regionsToAttach[ id ] = attachTo;
}
} );
const title = dom.querySelector( 'title' )?.innerText;
const initialData = parseServerData( dom );
// Wait for styles and modules to be ready.
const [ styles, scriptModules ] = await Promise.all( [
Promise.all( preloadStyles( dom ) ),
Promise.all( preloadScriptModules( dom ) ),
] );
return {
regions,
regionsToAttach,
styles,
scriptModules,
title,
initialData,
url,
};
};
/**
* Renders a page by applying styles, populating server data, rendering regions,
* and updating the document title.
*
* @param page The {@link Page} object to render.
*/
const renderPage = ( page: Page ) => {
applyStyles( page.styles );
// Clone regionsToAttach.
const regionsToAttach = { ...page.regionsToAttach };
batch( () => {
// Updates the server data.
populateServerData( page.initialData );
// Triggers navigation invalidations (`getServerState` and
// `getServerContext`).
navigationSignal.value += 1;
// Resets all router regions before setting the actual values.
( routerRegions as Map< string, any > ).forEach( ( signal ) => {
signal.value = null;
} );
// Inits regions with attachTo that don't exist yet.
const parentsToUpdate = new Set< Element >();
for ( const id in regionsToAttach ) {
const parent = document.querySelector( regionsToAttach[ id ] );
if ( ! regionsToAttachByParent.has( parent ) ) {
regionsToAttachByParent.set( parent, [] );
}
const regions = regionsToAttachByParent.get( parent );
if ( ! regions.includes( id ) ) {
regions.push( id );
parentsToUpdate.add( parent );
}
}
// Updates all existing regions.
for ( const id in page.regions ) {
if ( routerRegions.has( id ) ) {
routerRegions.get( id ).value = cloneRouterRegionContent(
page.regions[ id ]
);
}
}
// Renders regions attached to the same parent in the same fragment.
parentsToUpdate.forEach( ( parent ) => {
const ids = regionsToAttachByParent.get( parent );
const vdoms = ids.map( ( id ) => page.regions[ id ] );
if ( ! rootFragmentsByParent.has( parent ) ) {
const regions = vdoms.map( ( { props, type } ) => {
const elementType =
typeof type === 'function' ? props.type : type;
// Creates an element with the obtained type where the
// region will be rendered. The type should match the one of
// the root vnode.
const region = document.createElement( elementType );
parent.appendChild( region );
return region;
} );
rootFragmentsByParent.set(
parent,
getRegionRootFragment( regions )
);
}
const fragment = rootFragmentsByParent.get( parent );
render( vdoms, fragment );
} );
} );
if ( page.title ) {
document.title = page.title;
}
};
/**
* Loads the given page forcing a full page reload.
*
* The function returns a promise that won't resolve, useful to prevent any
* potential feedback indicating that the navigation has finished while the new
* page is being loaded.
*
* @param href The page href.
* @return Promise that never resolves.
*/
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.href ); // Remove hash.
const page = pages.has( pagePath ) && ( await pages.get( pagePath ) );
if ( page ) {
batch( () => {
state.url = window.location.href;
renderPage( page );
} );
} else {
window.location.reload();
}
} );
// Detect router regions with `attachTo` in the initial page. This step should
// be done before the initial page is processed with `preparePage()` so this
// function treats them as regular router regions.
document.querySelectorAll( regionsSelector ).forEach( ( region ) => {
const { id, attachTo } = parseRegionAttribute( region );
if ( attachTo ) {
initialRegionsToAttach.add( id );
}
} );
// Initialize the router and cache the initial page using the initial vDOM.
window.document
.querySelectorAll< HTMLScriptElement >( 'script[type=module][src]' )
.forEach( ( { src } ) => markScriptModuleAsResolved( src ) );
// Await hydration completion before setting the initial page to ensure initialVdom is populated.
( async () => {
const initialVdomMap = await initialVdomPromise;
pages.set(
getPagePath( window.location.href ),
Promise.resolve(
preparePage( getPagePath( window.location.href ), document, {
vdom: initialVdomMap,
} )
)
);
} )();
// Variable to store the current navigation.
let navigatingTo = '';
let hasLoadedNavigationTextsData = false;
const navigationTexts = {
loading: 'Loading page, please wait.',
loaded: 'Page Loaded.',
};
interface Store {
state: {
url: string;
navigation: {
hasStarted: boolean;
hasFinished: boolean;
};
};
actions: {
navigate: (
href: string,
options?: NavigateOptions
) => Promise< void >;
prefetch: ( url: string, options?: PrefetchOptions ) => Promise< void >;
};
}
const { state: privateState } = store(
'core/router/private',
{
state: {
navigation: {
hasStarted: false,
hasFinished: false,
},
},
},
{ lock: true }
);
export const { state, actions } = store< Store >( 'core/router', {
state: {
get navigation() {
if ( globalThis.SCRIPT_DEBUG ) {
warn(
`The usage of state.navigation.{hasStarted|hasFinished} from core/router is deprecated and will stop working in WordPress 7.1.`
);
}
return privateState.navigation;
},
},
actions: {
/**
* Navigates to the specified page.
*
* This function normalizes the passed href, fetches the page HTML if
* needed, and updates any interactive regions whose contents have
* changed. It also creates a new entry in the browser session history.
*
* @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 that resolves once the navigation is completed or aborted.
*/
*navigate( href: string, options: NavigateOptions = {} ) {
const { clientNavigationDisabled } = getConfig();
if ( clientNavigationDisabled ) {
yield forcePageReload( href );
}
const pagePath = getPagePath( href );
const { navigation } = privateState;
const {
loadingAnimation = true,
screenReaderAnnouncement = true,
timeout = 10000,
} = options;
navigatingTo = href;
actions.prefetch( pagePath, options );
// Creates a promise that resolves when the specified timeout ends.
// The timeout value is 10 seconds by default.
const timeoutPromise = new Promise< void >( ( resolve ) =>
setTimeout( resolve, timeout )
);
// Doesn't update the navigation status immediately, wait 400 ms.
const loadingTimeout = setTimeout( () => {
if ( navigatingTo !== href ) {
return;
}
if ( loadingAnimation ) {
navigation.hasStarted = true;
navigation.hasFinished = false;
}
if ( screenReaderAnnouncement ) {
a11ySpeak( 'loading' );
}
}, 400 );
const page = yield Promise.race( [
pages.get( pagePath ),
timeoutPromise,
] );
// Dismisses loading message if it hasn't been added yet.
clearTimeout( loadingTimeout );
// Once the page is fetched, the destination URL could have changed
// (e.g., by clicking another link in the meantime). If so, bail
// out, and let the newer execution to update the HTML.
if ( navigatingTo !== href ) {
return;
}
if (
page &&
! page.initialData?.config?.[ 'core/router' ]
?.clientNavigationDisabled
) {
yield importScriptModules( page.scriptModules );
batch( () => {
// Updates the URL in the state.
state.url = href;
// Updates the navigation status once the the new page rendering
// has been completed.
if ( loadingAnimation ) {
navigation.hasStarted = false;
navigation.hasFinished = true;
}
// Renders the new page.
renderPage( page );
} );
window.history[
options.replace ? 'replaceState' : 'pushState'
]( { wpInteractivityId: sessionId }, '', href );
if ( screenReaderAnnouncement ) {
a11ySpeak( 'loaded' );
}
// Scroll to the anchor if exits in the link.
const { hash } = new URL( href, window.location.href );
if ( hash ) {
document.querySelector( hash )?.scrollIntoView();
}
} else {
yield forcePageReload( href );
}
},
/**
* Prefetches the page with the passed URL.
*
* The function normalizes the URL and stores internally the fetch
* promise, to avoid triggering a second fetch for an ongoing request.
*
* @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.
*
* @return Promise that resolves once the page has been fetched.
*/
*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, { html: options.html } )
);
}
yield pages.get( pagePath );
},
},
} );
// Initialize the URL in the state if it hasn't been set yet in the server.
state.url = state.url || window.location.href;
/**
* Announces a message to screen readers.
*
* This is a wrapper around the `@wordpress/a11y` package's `speak` function. It handles importing
* the package on demand and should be used instead of calling `a11y.speak` directly.
*
* @param messageKey The message to be announced by assistive technologies.
*/
function a11ySpeak( messageKey: keyof typeof navigationTexts ) {
if ( ! hasLoadedNavigationTextsData ) {
hasLoadedNavigationTextsData = true;
const content = document.getElementById(
'wp-script-module-data-@wordpress/interactivity-router'
)?.textContent;
if ( content ) {
try {
const parsed = JSON.parse( content );
if ( typeof parsed?.i18n?.loading === 'string' ) {
navigationTexts.loading = parsed.i18n.loading;
}
if ( typeof parsed?.i18n?.loaded === 'string' ) {
navigationTexts.loaded = parsed.i18n.loaded;
}
} catch {}
} else {
// Fallback to localized strings from Interactivity API state.
// @todo This block is for Core < 6.7.0. Remove when support is dropped.
// @ts-expect-error
if ( state.navigation.texts?.loading ) {
// @ts-expect-error
navigationTexts.loading = state.navigation.texts.loading;
}
// @ts-expect-error
if ( state.navigation.texts?.loaded ) {
// @ts-expect-error
navigationTexts.loaded = state.navigation.texts.loaded;
}
}
}
const message = navigationTexts[ messageKey ];
import( '@wordpress/a11y' ).then(
( { speak } ) => speak( message ),
// Ignore failures to load the a11y module.
() => {}
);
}