diff --git a/assets/js/interactivity/directives.js b/assets/js/interactivity/directives.js index a306b7df9fe..358f3b2eeb0 100644 --- a/assets/js/interactivity/directives.js +++ b/assets/js/interactivity/directives.js @@ -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,40 +148,54 @@ 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(); // Fetch the page (or return it from cache). await navigate( href ); + // Scroll to the element if it's defined. + let topPosition = 0; + if ( link?.scrollToSelector ) { + const element = document.querySelector( + link?.scrollToSelector + ); + + if ( element ) { + topPosition = + element.getBoundingClientRect().top + + window.scrollY; + } + } + // Update the scroll, depending on the option. True by default. if ( link?.scroll === 'smooth' ) { window.scrollTo( { - top: 0, + top: topPosition, left: 0, behavior: 'smooth', } ); } else if ( link?.scroll !== false ) { - window.scrollTo( 0, 0 ); + window.scrollTo( 0, topPosition ); } }; } diff --git a/assets/js/interactivity/router.js b/assets/js/interactivity/router.js index 780371dd764..85a9964ed1f 100644 --- a/assets/js/interactivity/router.js +++ b/assets/js/interactivity/router.js @@ -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 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 ); - } ) - ); +// Fetch a new page and convert it to a static virtual DOM. +const fetchPage = async ( url ) => { + 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 fetchedItems.map( ( item ) => { - const element = document.createElement( elementToCreate ); - element.textContent = item; - return element; - } ); + return regionsToVdom( dom ); }; -// 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 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 [ - ...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 ) }; + 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 ) ) + ); }; diff --git a/src/BlockTypes/ProductQuery.php b/src/BlockTypes/ProductQuery.php index 8916d615683..f4ec5af534c 100644 --- a/src/BlockTypes/ProductQuery.php +++ b/src/BlockTypes/ProductQuery.php @@ -79,6 +79,83 @@ protected function initialize() { ); 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, + 'scrollToSelector' => '[data-wc-navigation-id="woo-products-' . $this->parsed_block['attrs']['queryId'] . '"]', + ); + + $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; } /** diff --git a/src/Interactivity/client-side-navigation.php b/src/Interactivity/client-side-navigation.php deleted file mode 100644 index ab550070f61..00000000000 --- a/src/Interactivity/client-side-navigation.php +++ /dev/null @@ -1,9 +0,0 @@ -'; -} -add_action( 'wp_head', 'woocommerce_interactivity_add_client_side_navigation_meta_tag' ); diff --git a/src/Interactivity/load.php b/src/Interactivity/load.php index 8838971f099..59358429242 100644 --- a/src/Interactivity/load.php +++ b/src/Interactivity/load.php @@ -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'; diff --git a/woocommerce-gutenberg-products-block.php b/woocommerce-gutenberg-products-block.php index 60f54a154c7..121099bee07 100644 --- a/woocommerce-gutenberg-products-block.php +++ b/woocommerce-gutenberg-products-block.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 ) {