@wordpress/interactivity-router
Version:
Package that exposes state and actions from the `core/router` store, part of the Interactivity API.
257 lines (236 loc) • 7.96 kB
text/typescript
/**
* Internal dependencies
*/
import { shortestCommonSupersequence } from './scs';
export type StyleElement = HTMLLinkElement | HTMLStyleElement;
/**
* Compares the passed style or link elements to check if they can be
* considered equal.
*
* @param a `<style>` or `<link>` element.
* @param b `<style>` or `<link>` element.
* @return Whether they are considered equal.
*/
const areNodesEqual = ( a: StyleElement, b: StyleElement ): boolean =>
a.isEqualNode( b );
/**
* Normalizes the passed style or link element, reverting the changes
* made by {@link prepareStylePromise|`prepareStylePromise`} to the
* `data-original-media` and `media`.
*
* @example
* The following elements should be normalized to the same element:
* ```html
* <link rel="stylesheet" src="./assets/styles.css">
* <link rel="stylesheet" src="./assets/styles.css" media="all">
* <link rel="stylesheet" src="./assets/styles.css" media="preload">
* <link rel="stylesheet" src="./assets/styles.css" media="preload" data-original-media="all">
* ```
*
* @param element `<style>` or `<link>` element.
* @return Normalized node.
*/
export const normalizeMedia = ( element: StyleElement ): StyleElement => {
element = element.cloneNode( true ) as StyleElement;
const media = element.media;
const { originalMedia } = element.dataset;
if ( media === 'preload' ) {
element.media = originalMedia || 'all';
element.removeAttribute( 'data-original-media' );
} else if ( ! element.media ) {
element.media = 'all';
}
return element;
};
/**
* Adds the minimum style elements from Y around those in X using a
* shortest common supersequence algorithm, returning a list of
* promises for all the elements in Y.
*
* If X is empty, it appends all elements in Y to the passed parent
* element or to `document.head` instead.
*
* The returned promises resolve once the corresponding style element
* is loaded and ready. Those elements that are also in X return a
* cached promise.
*
* The algorithm ensures that the final style elements present in the
* document (or the passed `parent` element) are in the correct order
* and they are included in either X or Y.
*
* @param X Base list of style elements.
* @param Y List of style elements.
* @param parent Optional parent element to append to the new style elements.
* @return List of promises that resolve once the elements in Y are ready.
*/
export function updateStylesWithSCS(
X: StyleElement[],
Y: StyleElement[],
parent: Element = window.document.head
) {
if ( X.length === 0 ) {
return Y.map( ( element ) => {
const promise = prepareStylePromise( element );
parent.appendChild( element );
return promise;
} );
}
// Create normalized arrays for comparison.
const xNormalized = X.map( normalizeMedia );
const yNormalized = Y.map( normalizeMedia );
// The `scs` array contains normalized elements.
const scs = shortestCommonSupersequence(
xNormalized,
yNormalized,
areNodesEqual
);
const xLength = X.length;
const yLength = Y.length;
const promises = [];
let last = X[ xLength - 1 ];
let xIndex = 0;
let yIndex = 0;
for ( const scsElement of scs ) {
// Actual elements that will end up in the DOM.
const xElement = X[ xIndex ];
const yElement = Y[ yIndex ];
// Normalized elements for comparison.
const xNormEl = xNormalized[ xIndex ];
const yNormEl = yNormalized[ yIndex ];
if ( xIndex < xLength && areNodesEqual( xNormEl, scsElement ) ) {
if ( yIndex < yLength && areNodesEqual( yNormEl, scsElement ) ) {
promises.push( prepareStylePromise( xElement ) );
yIndex++;
}
xIndex++;
} else {
promises.push( prepareStylePromise( yElement ) );
if ( xIndex < xLength ) {
xElement.before( yElement );
} else {
last.after( yElement );
last = yElement;
}
yIndex++;
}
}
return promises;
}
/**
* Cache of promises per style elements.
*
* Each style element has their own associated `Promise` that resolves
* once the element has been loaded and is ready.
*/
const stylePromiseCache = new WeakMap<
StyleElement,
Promise< StyleElement >
>();
/**
* Prepares and returns the corresponding `Promise` for the passed style
* element.
*
* It returns the cached promise if it exists. Otherwise, constructs
* a `Promise` that resolves once the element has finished loading.
*
* For those elements that are not in the DOM yet, this function
* injects a `media="preload"` attribute to the passed element so the
* style is loaded without applying any styles to the document.
*
* @param element Style element.
* @return The associated `Promise` to the passed element.
*/
const prepareStylePromise = (
element: StyleElement
): Promise< StyleElement > => {
if ( stylePromiseCache.has( element ) ) {
return stylePromiseCache.get( element );
}
// When the element exists in the main document and its media attribute
// is not "preload", that means the element comes from the initial page.
// The `media` attribute doesn't need to be handled in this case.
if ( window.document.contains( element ) && element.media !== 'preload' ) {
const promise = Promise.resolve( element );
stylePromiseCache.set( element, promise );
return promise;
}
if ( element.hasAttribute( 'media' ) && element.media !== 'all' ) {
element.dataset.originalMedia = element.media;
}
element.media = 'preload';
if ( element instanceof HTMLStyleElement ) {
const promise = Promise.resolve( element );
stylePromiseCache.set( element, promise );
return promise;
}
const promise = new Promise< HTMLLinkElement >( ( resolve, reject ) => {
element.addEventListener( 'load', () => resolve( element ) );
element.addEventListener( 'error', ( event ) => {
const { href } = event.target as HTMLLinkElement;
reject(
Error(
`The style sheet with the following URL failed to load: ${ href }`
)
);
} );
} );
stylePromiseCache.set( element, promise );
return promise;
};
/**
* Prepares all style elements contained in the passed document.
*
* This function calls {@link updateStylesWithSCS|`updateStylesWithSCS`}
* to insert only the minimum amount of style elements into the DOM, so
* those present in the passed document end up in the DOM while the order
* is respected.
*
* New appended style elements contain a `media=preload` attribute to
* make them effectively disabled until they are applied with the
* {@link applyStyles|`applyStyles`} function.
*
* Note that this function alters the passed document, as it can transfer
* nodes from it to the global document.
*
* @param doc Document instance.
* @return A list of promises for each style element in the passed document.
*/
export const preloadStyles = ( doc: Document ): Promise< StyleElement >[] => {
const currentStyleElements = Array.from(
window.document.querySelectorAll< StyleElement >(
'style,link[rel=stylesheet]'
)
);
const newStyleElements = Array.from(
doc.querySelectorAll< StyleElement >( 'style,link[rel=stylesheet]' )
);
// Set styles in order.
return updateStylesWithSCS( currentStyleElements, newStyleElements );
};
/**
* Traverses all style elements in the DOM, enabling only those included
* in the passed list and disabling the others.
*
* If the style element has the `data-original-media` attribute, the
* original `media` value is restored.
*
* @param styles List of style elements to apply.
*/
export const applyStyles = ( styles: StyleElement[] ) => {
window.document
.querySelectorAll( 'style,link[rel=stylesheet]' )
.forEach( ( el: HTMLLinkElement | HTMLStyleElement ) => {
if ( el.sheet ) {
if ( styles.includes( el ) ) {
// Only update mediaText when necessary.
if ( el.sheet.media.mediaText === 'preload' ) {
const { originalMedia = 'all' } = el.dataset;
el.sheet.media.mediaText = originalMedia;
}
el.sheet.disabled = false;
} else {
el.sheet.disabled = true;
}
}
} );
};