Interactivity API: Update interactive regions during client-side navigation (https://github.com/woocommerce/woocommerce-blocks/pull/10200)
* Update router to hydrate only interactive regions * Rename link directive to navigation-link * Add navigation directives to Query and Pagination blocks * Enable the Interactivity API by default * Remove client-side navigation meta tag * Cache initial regions * Move data-wc-interactive from query to query-pagination * Add woo prefix to navigation id * Add keys and move wc-interactive back to the query block * Reuse root fragments for each interactive region * Fix navigation-id retrieval
This commit is contained in:
parent
41e5f068b5
commit
84f4cec592
|
@ -2,10 +2,7 @@ import { useContext, useMemo, useEffect } from 'preact/hooks';
|
|||
import { deepSignal, peek } from 'deepsignal';
|
||||
import { useSignalEffect } from './utils';
|
||||
import { directive } from './hooks';
|
||||
import { prefetch, navigate, canDoClientSideNavigation } from './router';
|
||||
|
||||
// Check if current page can do client-side navigation.
|
||||
const clientSideNavigation = canDoClientSideNavigation( document.head );
|
||||
import { prefetch, navigate } from './router';
|
||||
|
||||
const isObject = ( item ) =>
|
||||
item && typeof item === 'object' && ! Array.isArray( item );
|
||||
|
@ -151,25 +148,25 @@ export default () => {
|
|||
}
|
||||
);
|
||||
|
||||
// data-wc-link
|
||||
// data-wc-navigation-link
|
||||
directive(
|
||||
'link',
|
||||
'navigation-link',
|
||||
( {
|
||||
directives: {
|
||||
link: { default: link },
|
||||
'navigation-link': { default: link },
|
||||
},
|
||||
props: { href },
|
||||
element,
|
||||
} ) => {
|
||||
useEffect( () => {
|
||||
// Prefetch the page if it is in the directive options.
|
||||
if ( clientSideNavigation && link?.prefetch ) {
|
||||
if ( link?.prefetch ) {
|
||||
prefetch( href );
|
||||
}
|
||||
} );
|
||||
|
||||
// Don't do anything if it's falsy.
|
||||
if ( clientSideNavigation && link !== false ) {
|
||||
if ( link !== false ) {
|
||||
element.props.onclick = async ( event ) => {
|
||||
event.preventDefault();
|
||||
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
import { hydrate, render } from 'preact';
|
||||
import { toVdom, hydratedIslands } from './vdom';
|
||||
import { createRootFragment } from './utils';
|
||||
import { csnMetaTagItemprop, directivePrefix } from './constants';
|
||||
import { directivePrefix } from './constants';
|
||||
|
||||
// The root to render the vdom (document.body).
|
||||
let rootFragment;
|
||||
|
||||
// The cache of visited and prefetched pages, stylesheets and scripts.
|
||||
// The cache of visited and prefetched pages.
|
||||
const pages = new Map();
|
||||
const stylesheets = new Map();
|
||||
const scripts = new Map();
|
||||
|
||||
// Keep the same root fragment for each interactive region node.
|
||||
const regionRootFragments = new WeakMap();
|
||||
const getRegionRootFragment = ( region ) => {
|
||||
if ( ! regionRootFragments.has( region ) ) {
|
||||
regionRootFragments.set(
|
||||
region,
|
||||
createRootFragment( region.parentElement, region )
|
||||
);
|
||||
}
|
||||
return regionRootFragments.get( region );
|
||||
};
|
||||
|
||||
// Helper to remove domain and hash from the URL. We are only interesting in
|
||||
// caching the path and the query.
|
||||
|
@ -18,94 +25,32 @@ const cleanUrl = ( url ) => {
|
|||
return u.pathname + u.search;
|
||||
};
|
||||
|
||||
// Helper to check if a page can do client-side navigation.
|
||||
export const canDoClientSideNavigation = ( dom ) =>
|
||||
dom
|
||||
.querySelector( `meta[itemprop='${ csnMetaTagItemprop }']` )
|
||||
?.getAttribute( 'content' ) === 'active';
|
||||
|
||||
/**
|
||||
* Finds the elements in the document that match the selector and fetch them.
|
||||
* For each element found, fetch the content and store it in the cache.
|
||||
* Returns an array of elements to add to the document.
|
||||
*
|
||||
* @param {Document} document
|
||||
* @param {string} selector - CSS selector used to find the elements.
|
||||
* @param {'href'|'src'} attribute - Attribute that determines where to fetch
|
||||
* the styles or scripts from. Also used as the key for the cache.
|
||||
* @param {Map} cache - Cache to use for the elements. Can be `stylesheets` or `scripts`.
|
||||
* @param {'style'|'script'} elementToCreate - Element to create for each fetched
|
||||
* item. Can be 'style' or 'script'.
|
||||
* @return {Promise<Array<HTMLElement>>} - Array of elements to add to the document.
|
||||
*/
|
||||
const fetchScriptOrStyle = async (
|
||||
document,
|
||||
selector,
|
||||
attribute,
|
||||
cache,
|
||||
elementToCreate
|
||||
) => {
|
||||
const fetchedItems = await Promise.all(
|
||||
[].map.call( document.querySelectorAll( selector ), ( el ) => {
|
||||
const attributeValue = el.getAttribute( attribute );
|
||||
if ( ! cache.has( attributeValue ) )
|
||||
cache.set(
|
||||
attributeValue,
|
||||
fetch( attributeValue ).then( ( r ) => r.text() )
|
||||
);
|
||||
return cache.get( attributeValue );
|
||||
} )
|
||||
);
|
||||
|
||||
return fetchedItems.map( ( item ) => {
|
||||
const element = document.createElement( elementToCreate );
|
||||
element.textContent = item;
|
||||
return element;
|
||||
} );
|
||||
};
|
||||
|
||||
// Fetch styles of a new page.
|
||||
const fetchAssets = async ( document ) => {
|
||||
const stylesFromSheets = await fetchScriptOrStyle(
|
||||
document,
|
||||
'link[rel=stylesheet]',
|
||||
'href',
|
||||
stylesheets,
|
||||
'style'
|
||||
);
|
||||
const scriptTags = await fetchScriptOrStyle(
|
||||
document,
|
||||
'script[src]',
|
||||
'src',
|
||||
scripts,
|
||||
'script'
|
||||
);
|
||||
const moduleScripts = await fetchScriptOrStyle(
|
||||
document,
|
||||
'script[type=module]',
|
||||
'src',
|
||||
scripts,
|
||||
'script'
|
||||
);
|
||||
moduleScripts.forEach( ( script ) =>
|
||||
script.setAttribute( 'type', 'module' )
|
||||
);
|
||||
|
||||
return [
|
||||
...scriptTags,
|
||||
document.querySelector( 'title' ),
|
||||
...document.querySelectorAll( 'style' ),
|
||||
...stylesFromSheets,
|
||||
];
|
||||
};
|
||||
|
||||
// Fetch a new page and convert it to a static virtual DOM.
|
||||
const fetchPage = async ( url ) => {
|
||||
const html = await window.fetch( url ).then( ( r ) => r.text() );
|
||||
const dom = new window.DOMParser().parseFromString( html, 'text/html' );
|
||||
if ( ! canDoClientSideNavigation( dom.head ) ) return false;
|
||||
const head = await fetchAssets( dom );
|
||||
return { head, body: toVdom( dom.body ) };
|
||||
let dom;
|
||||
try {
|
||||
const res = await window.fetch( url );
|
||||
if ( res.status !== 200 ) return false;
|
||||
const html = await res.text();
|
||||
dom = new window.DOMParser().parseFromString( html, 'text/html' );
|
||||
} catch ( e ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return regionsToVdom( dom );
|
||||
};
|
||||
|
||||
// Return an object with VDOM trees of those HTML regions marked with a
|
||||
// `navigation-id` directive.
|
||||
const regionsToVdom = ( dom ) => {
|
||||
const regions = {};
|
||||
const attrName = `data-${ directivePrefix }-navigation-id`;
|
||||
dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
|
||||
const id = region.getAttribute( attrName );
|
||||
regions[ id ] = toVdom( region );
|
||||
} );
|
||||
|
||||
return { regions };
|
||||
};
|
||||
|
||||
// Prefetch a page. We store the promise to avoid triggering a second fetch for
|
||||
|
@ -117,14 +62,23 @@ export const prefetch = ( url ) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Render all interactive regions contained in the given page.
|
||||
const renderRegions = ( page ) => {
|
||||
const attrName = `data-${ directivePrefix }-navigation-id`;
|
||||
document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
|
||||
const id = region.getAttribute( attrName );
|
||||
const fragment = getRegionRootFragment( region );
|
||||
render( page.regions[ id ], fragment );
|
||||
} );
|
||||
};
|
||||
|
||||
// Navigate to a new page.
|
||||
export const navigate = async ( href, { replace = false } = {} ) => {
|
||||
const url = cleanUrl( href );
|
||||
prefetch( url );
|
||||
const page = await pages.get( url );
|
||||
if ( page ) {
|
||||
document.head.replaceChildren( ...page.head );
|
||||
render( page.body, rootFragment );
|
||||
renderRegions( page );
|
||||
window.history[ replace ? 'replaceState' : 'pushState' ](
|
||||
{},
|
||||
'',
|
||||
|
@ -141,8 +95,7 @@ window.addEventListener( 'popstate', async () => {
|
|||
const url = cleanUrl( window.location ); // Remove hash.
|
||||
const page = pages.has( url ) && ( await pages.get( url ) );
|
||||
if ( page ) {
|
||||
document.head.replaceChildren( ...page.head );
|
||||
render( page.body, rootFragment );
|
||||
renderRegions( page );
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
|
@ -150,37 +103,19 @@ window.addEventListener( 'popstate', async () => {
|
|||
|
||||
// Initialize the router with the initial DOM.
|
||||
export const init = async () => {
|
||||
if ( canDoClientSideNavigation( document.head ) ) {
|
||||
// Create the root fragment to hydrate everything.
|
||||
rootFragment = createRootFragment(
|
||||
document.documentElement,
|
||||
document.body
|
||||
);
|
||||
const body = toVdom( document.body );
|
||||
hydrate( body, rootFragment );
|
||||
|
||||
// Cache the scripts. Has to be called before fetching the assets.
|
||||
[].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => {
|
||||
scripts.set( script.getAttribute( 'src' ), script.textContent );
|
||||
document
|
||||
.querySelectorAll( `[data-${ directivePrefix }-interactive]` )
|
||||
.forEach( ( node ) => {
|
||||
if ( ! hydratedIslands.has( node ) ) {
|
||||
const fragment = getRegionRootFragment( node );
|
||||
const vdom = toVdom( node );
|
||||
hydrate( vdom, fragment );
|
||||
}
|
||||
} );
|
||||
|
||||
const head = await fetchAssets( document );
|
||||
pages.set(
|
||||
cleanUrl( window.location ),
|
||||
Promise.resolve( { body, head } )
|
||||
);
|
||||
} else {
|
||||
document
|
||||
.querySelectorAll( `[data-${ directivePrefix }-interactive]` )
|
||||
.forEach( ( node ) => {
|
||||
if ( ! hydratedIslands.has( node ) ) {
|
||||
const fragment = createRootFragment(
|
||||
node.parentNode,
|
||||
node
|
||||
);
|
||||
const vdom = toVdom( node );
|
||||
hydrate( vdom, fragment );
|
||||
}
|
||||
} );
|
||||
}
|
||||
// Cache the current regions.
|
||||
pages.set(
|
||||
cleanUrl( window.location ),
|
||||
Promise.resolve( regionsToVdom( document ) )
|
||||
);
|
||||
};
|
||||
|
|
|
@ -80,6 +80,82 @@ class ProductQuery extends AbstractBlock {
|
|||
);
|
||||
add_filter( 'rest_product_query', array( $this, 'update_rest_query' ), 10, 2 );
|
||||
add_filter( 'rest_product_collection_params', array( $this, 'extend_rest_query_allowed_params' ), 10, 1 );
|
||||
add_filter( 'render_block_core/query', array( $this, 'add_navigation_id_directive' ), 10, 3 );
|
||||
add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the Product Query as an interactive region so it can be updated
|
||||
* during client-side navigation.
|
||||
*
|
||||
* @param string $block_content The block content.
|
||||
* @param array $block The full block, including name and attributes.
|
||||
* @param \WP_Block $instance The block instance.
|
||||
*/
|
||||
public function add_navigation_id_directive( $block_content, $block, $instance ) {
|
||||
if ( self::is_woocommerce_variation( $block ) ) {
|
||||
// Enqueue the Interactivity API runtime.
|
||||
wp_enqueue_script( 'wc-interactivity' );
|
||||
|
||||
$p = new \WP_HTML_Tag_Processor( $block_content );
|
||||
|
||||
// Add `data-wc-navigation-id to the query block.
|
||||
if ( $p->next_tag( array( 'class_name' => 'wp-block-query' ) ) ) {
|
||||
$p->set_attribute(
|
||||
'data-wc-navigation-id',
|
||||
'woo-products-' . $block['attrs']['queryId']
|
||||
);
|
||||
$p->set_attribute( 'data-wc-interactive', true );
|
||||
$block_content = $p->get_updated_html();
|
||||
}
|
||||
}
|
||||
|
||||
return $block_content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add interactive links to all anchors inside the Query Pagination block.
|
||||
*
|
||||
* @param string $block_content The block content.
|
||||
* @param array $block The full block, including name and attributes.
|
||||
* @param \WP_Block $instance The block instance.
|
||||
*/
|
||||
public function add_navigation_link_directives( $block_content, $block, $instance ) {
|
||||
if (
|
||||
self::is_woocommerce_variation( $this->parsed_block ) &&
|
||||
$instance->context['queryId'] === $this->parsed_block['attrs']['queryId']
|
||||
) {
|
||||
$p = new \WP_HTML_Tag_Processor( $block_content );
|
||||
$p->next_tag( array( 'class_name' => 'wp-block-query-pagination' ) );
|
||||
|
||||
while ( $p->next_tag( 'a' ) ) {
|
||||
$class_attr = $p->get_attribute( 'class' );
|
||||
$class_list = preg_split( '/\s+/', $class_attr );
|
||||
|
||||
$is_previous = in_array( 'wp-block-query-pagination-previous', $class_list, true );
|
||||
$is_next = in_array( 'wp-block-query-pagination-next', $class_list, true );
|
||||
$is_previous_or_next = $is_previous || $is_next;
|
||||
|
||||
$navigation_link_payload = array(
|
||||
'prefetch' => $is_previous_or_next,
|
||||
'scroll' => true,
|
||||
);
|
||||
|
||||
$p->set_attribute(
|
||||
'data-wc-navigation-link',
|
||||
wp_json_encode( $navigation_link_payload )
|
||||
);
|
||||
|
||||
if ( $is_previous ) {
|
||||
$p->set_attribute( 'key', 'pagination-previous' );
|
||||
} elseif ( $is_next ) {
|
||||
$p->set_attribute( 'key', 'pagination-next' );
|
||||
}
|
||||
}
|
||||
$block_content = $p->get_updated_html();
|
||||
}
|
||||
|
||||
return $block_content;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Print client-side navigation meta tag (hard-coded for now).
|
||||
*/
|
||||
function woocommerce_interactivity_add_client_side_navigation_meta_tag() {
|
||||
echo '<meta itemprop="wc-client-side-navigation" content="active">';
|
||||
}
|
||||
add_action( 'wp_head', 'woocommerce_interactivity_add_client_side_navigation_meta_tag' );
|
|
@ -2,4 +2,3 @@
|
|||
require __DIR__ . '/class-wc-interactivity-store.php';
|
||||
require __DIR__ . '/store.php';
|
||||
require __DIR__ . '/scripts.php';
|
||||
require __DIR__ . '/client-side-navigation.php';
|
||||
|
|
|
@ -309,7 +309,7 @@ function woocommerce_blocks_interactivity_setup() {
|
|||
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
|
||||
$is_enabled = apply_filters(
|
||||
'woocommerce_blocks_enable_interactivity_api',
|
||||
false
|
||||
true
|
||||
);
|
||||
|
||||
if ( $is_enabled ) {
|
||||
|
|
Loading…
Reference in New Issue