Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Interactivity API: Update interactive regions during client-side navigation #10200

Merged
merged 12 commits into from
Aug 3, 2023
Merged
15 changes: 6 additions & 9 deletions assets/js/interactivity/directives.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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();

Expand Down
163 changes: 38 additions & 125 deletions assets/js/interactivity/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import { csnMetaTagItemprop, 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();

// Helper to remove domain and hash from the URL. We are only interesting in
// caching the path and the query.
Expand All @@ -18,94 +16,24 @@ 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;
}
const regions = {};
dom.querySelectorAll( '[data-wc-navigation-id]' ).forEach( ( region ) => {
const id = region.attributes[ 'data-wc-navigation-id' ];
regions[ id ] = toVdom( region );
} );

return { regions };
};

// Prefetch a page. We store the promise to avoid triggering a second fetch for
Expand All @@ -117,14 +45,24 @@ export const prefetch = ( url ) => {
}
};

// Render all interactive regions contained in the given page.
const renderRegions = ( page ) => {
document
.querySelectorAll( '[data-wc-navigation-id]' )
.forEach( ( region ) => {
const id = region.attributes[ 'data-wc-navigation-id' ];
const fragment = createRootFragment( region.parentElement, 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' ](
{},
'',
Expand All @@ -141,46 +79,21 @@ 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();
}
} );

// 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 = createRootFragment( node.parentNode, node );
const vdom = toVdom( node );
hydrate( vdom, fragment );
}
} );

const head = await fetchAssets( document );
pages.set(
cleanUrl( window.location ),
Promise.resolve( { body, head } )
);
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
} 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 );
}
} );
}
};
54 changes: 54 additions & 0 deletions src/BlockTypes/ProductQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,60 @@ 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 ) ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I would add another check:

core/query' === $block['blockName'] && self::is_woocommerce_variation( $this->parsed_block )

The rest LGTM!

// 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-interactive', true );
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
$p->set_attribute( 'data-wc-navigation-id', $block['attrs']['queryId'] );
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
$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']
) {
Comment on lines +123 to +126
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I used this check to ensure that we're adding the navigation-link directive only to the links inside the Pagination Block contained in the Products Query block. If you think there's a better way to do this, feel free to update the code. 😊

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, it'd be great if someone from Woo could review this part and give a thumbs up 🙂

Copy link
Contributor

@gigitux gigitux Jul 24, 2023

Choose a reason for hiding this comment

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

I would add another check:

core/query' === $block['blockName'] && self::is_woocommerce_variation( $this->parsed_block )....

The rest LGTM!

$p = new \WP_HTML_Tag_Processor( $block_content );

while ( $p->next_tag( 'a' ) ) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Taking into account that this is not an experiment anymore, maybe we could start with a prefetch strategy of only prefetching the previous/next links, and skipping the page number links?

cc: @gigitux, @Aljullu, any thoughts on the prefetching strategy for the pagination?

Copy link
Contributor

Choose a reason for hiding this comment

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

Good question. I don't have a strong opinion, but prefetching the next and previous ones and skip the others makes sense to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'll make that change.

$p->set_attribute(
'data-wc-navigation-link',
'{"prefetch":true,"scroll":false}'
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess it depends on the use-case, but when using the Products block in the Product Catalog template, I think it should default to scrolling to top.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'll make this change as well. 🙂

);
}
$block_content = $p->get_updated_html();
}

return $block_content;
}

/**
Expand Down
9 changes: 0 additions & 9 deletions src/Interactivity/client-side-navigation.php

This file was deleted.

1 change: 0 additions & 1 deletion src/Interactivity/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 1 addition & 1 deletion woocommerce-gutenberg-products-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down