diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php
index f5d40ae8a2110..cecdf5545c675 100644
--- a/lib/experimental/editor-settings.php
+++ b/lib/experimental/editor-settings.php
@@ -28,6 +28,9 @@ function gutenberg_enable_experiments() {
if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) {
wp_add_inline_script( 'wp-block-library', 'window.__experimentalDisableTinymce = true', 'before' );
}
+ if ( gutenberg_is_experiment_enabled( 'gutenberg-full-page-client-side-navigation' ) ) {
+ wp_add_inline_script( 'wp-block-library', 'window.__experimentalFullPageClientSideNavigation = true', 'before' );
+ }
}
add_action( 'admin_init', 'gutenberg_enable_experiments' );
diff --git a/lib/experimental/full-page-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php
new file mode 100644
index 0000000000000..ebfddf4aaf436
--- /dev/null
+++ b/lib/experimental/full-page-client-side-navigation.php
@@ -0,0 +1,57 @@
+ 'fullPage' ) );
+ wp_enqueue_script_module( '@wordpress/interactivity-router' );
+}
+
+add_action( 'wp_enqueue_scripts', '_gutenberg_enqueue_interactivity_router' );
+
+/**
+ * Set enhancedPagination attribute for query loop when the experiment is enabled.
+ *
+ * @param array $parsed_block The parsed block.
+ *
+ * @return array The same parsed block with the modified attribute.
+ */
+function _gutenberg_add_enhanced_pagination_to_query_block( $parsed_block ) {
+ if ( 'core/query' !== $parsed_block['blockName'] ) {
+ return $parsed_block;
+ }
+
+ $parsed_block['attrs']['enhancedPagination'] = true;
+ return $parsed_block;
+}
+
+add_filter( 'render_block_data', '_gutenberg_add_enhanced_pagination_to_query_block' );
+
+/**
+ * Add directives to all links.
+ *
+ * Note: This should probably be done per site, not by default when this option is enabled.
+ *
+ * @param array $content The block content.
+ *
+ * @return array The same block content with the directives needed.
+ */
+function _gutenberg_add_client_side_navigation_directives( $content ) {
+ $p = new WP_HTML_Tag_Processor( $content );
+ // Hack to add the necessary directives to the body tag.
+ // TODO: Find a proper way to add directives to the body tag.
+ static $body_interactive_added;
+ if ( ! $body_interactive_added ) {
+ $body_interactive_added = true;
+ return (string) $p . '
';
+ }
+ return (string) $p;
+}
+
+// TODO: Explore moving this to the server directive processing.
+add_filter( 'render_block', '_gutenberg_add_client_side_navigation_directives' );
diff --git a/lib/experiments-page.php b/lib/experiments-page.php
index f66e093219263..9581859833759 100644
--- a/lib/experiments-page.php
+++ b/lib/experiments-page.php
@@ -127,6 +127,18 @@ function gutenberg_initialize_experiments_settings() {
)
);
+ add_settings_field(
+ 'gutenberg-full-page-client-side-navigation',
+ __( 'Enable full page client-side navigation', 'gutenberg' ),
+ 'gutenberg_display_experiment_field',
+ 'gutenberg-experiments',
+ 'gutenberg_experiments_section',
+ array(
+ 'label' => __( 'Enable full page client-side navigation using the Interactivity API', 'gutenberg' ),
+ 'id' => 'gutenberg-full-page-client-side-navigation',
+ )
+ );
+
register_setting(
'gutenberg-experiments',
'gutenberg-experiments'
diff --git a/lib/load.php b/lib/load.php
index 908f3fd6f36d4..ab53e16a6c304 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -192,6 +192,9 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/demo.php';
require __DIR__ . '/experiments-page.php';
require __DIR__ . '/interactivity-api.php';
+if ( gutenberg_is_experiment_enabled( 'gutenberg-full-page-client-side-navigation' ) ) {
+ require __DIR__ . '/experimental/full-page-client-side-navigation.php';
+}
// Copied package PHP files.
if ( is_dir( __DIR__ . '/../build/style-engine' ) ) {
diff --git a/packages/block-library/src/query/edit/enhanced-pagination-modal.js b/packages/block-library/src/query/edit/enhanced-pagination-modal.js
index 6009881c7bd86..4bc70ceb09961 100644
--- a/packages/block-library/src/query/edit/enhanced-pagination-modal.js
+++ b/packages/block-library/src/query/edit/enhanced-pagination-modal.js
@@ -27,7 +27,11 @@ export default function EnhancedPaginationModal( {
useUnsupportedBlocks( clientId );
useEffect( () => {
- if ( enhancedPagination && hasUnsupportedBlocks ) {
+ if (
+ enhancedPagination &&
+ hasUnsupportedBlocks &&
+ ! window.__experimentalFullPageClientSideNavigation
+ ) {
setAttributes( { enhancedPagination: false } );
setOpen( true );
}
diff --git a/packages/block-library/src/query/edit/inspector-controls/enhanced-pagination-control.js b/packages/block-library/src/query/edit/inspector-controls/enhanced-pagination-control.js
index de889c0715c07..293baead3f5c6 100644
--- a/packages/block-library/src/query/edit/inspector-controls/enhanced-pagination-control.js
+++ b/packages/block-library/src/query/edit/inspector-controls/enhanced-pagination-control.js
@@ -15,9 +15,15 @@ export default function EnhancedPaginationControl( {
clientId,
} ) {
const { hasUnsupportedBlocks } = useUnsupportedBlocks( clientId );
+ const fullPageClientSideNavigation =
+ window.__experimentalFullPageClientSideNavigation;
let help = __( 'Browsing between pages requires a full page reload.' );
- if ( enhancedPagination ) {
+ if ( fullPageClientSideNavigation ) {
+ help = __(
+ 'Experimental full-page client-side navigation setting enabled.'
+ );
+ } else if ( enhancedPagination ) {
help = __(
"Browsing between pages won't require a full page reload, unless non-compatible blocks are detected."
);
@@ -32,8 +38,12 @@ export default function EnhancedPaginationControl( {
{
setAttributes( {
enhancedPagination: ! value,
diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php
index b6c34eb71d070..88b3af438dbe5 100644
--- a/packages/block-library/src/query/index.php
+++ b/packages/block-library/src/query/index.php
@@ -51,8 +51,8 @@ function render_block_core_query( $attributes, $content, $block ) {
// Add the necessary directives.
$p->set_attribute( 'data-wp-interactive', 'core/query' );
$p->set_attribute( 'data-wp-router-region', 'query-' . $attributes['queryId'] );
- $p->set_attribute( 'data-wp-init', 'callbacks.setQueryRef' );
$p->set_attribute( 'data-wp-context', '{}' );
+ $p->set_attribute( 'data-wp-key', $attributes['queryId'] );
$content = $p->get_updated_html();
}
}
diff --git a/packages/interactivity-router/src/head.js b/packages/interactivity-router/src/head.js
new file mode 100644
index 0000000000000..a8cce487e592e
--- /dev/null
+++ b/packages/interactivity-router/src/head.js
@@ -0,0 +1,96 @@
+/**
+ * Helper to update only the necessary tags in the head.
+ *
+ * @async
+ * @param {Array} newHead The head elements of the new page.
+ *
+ */
+export const updateHead = async ( newHead ) => {
+ // Helper to get the tag id store in the cache.
+ const getTagId = ( tag ) => tag.id || tag.outerHTML;
+
+ // Map incoming head tags by their content.
+ const newHeadMap = new Map();
+ for ( const child of newHead ) {
+ newHeadMap.set( getTagId( child ), child );
+ }
+
+ const toRemove = [];
+
+ // Detect nodes that should be added or removed.
+ for ( const child of document.head.children ) {
+ const id = getTagId( child );
+ // Always remove styles and links as they might change.
+ if ( child.nodeName === 'LINK' || child.nodeName === 'STYLE' )
+ toRemove.push( child );
+ else if ( newHeadMap.has( id ) ) newHeadMap.delete( id );
+ else if ( child.nodeName !== 'SCRIPT' && child.nodeName !== 'META' )
+ toRemove.push( child );
+ }
+
+ // Prepare new assets.
+ const toAppend = [ ...newHeadMap.values() ];
+
+ // Apply the changes.
+ toRemove.forEach( ( n ) => n.remove() );
+ document.head.append( ...toAppend );
+};
+
+/**
+ * Fetches and processes head assets (stylesheets and scripts) from a specified document.
+ *
+ * @async
+ * @param {Document} doc The document from which to fetch head assets. It should support standard DOM querying methods.
+ * @param {Map} headElements A map of head elements to modify tracking the URLs of already processed assets to avoid duplicates.
+ *
+ * @return {Promise} Returns an array of HTML elements representing the head assets.
+ */
+export const fetchHeadAssets = async ( doc, headElements ) => {
+ const headTags = [];
+ const assets = [
+ {
+ tagName: 'style',
+ selector: 'link[rel=stylesheet]',
+ attribute: 'href',
+ },
+ { tagName: 'script', selector: 'script[src]', attribute: 'src' },
+ ];
+ for ( const asset of assets ) {
+ const { tagName, selector, attribute } = asset;
+ const tags = doc.querySelectorAll( selector );
+
+ // Use Promise.all to wait for fetch to complete
+ await Promise.all(
+ Array.from( tags ).map( async ( tag ) => {
+ const attributeValue = tag.getAttribute( attribute );
+ if ( ! headElements.has( attributeValue ) ) {
+ try {
+ const response = await fetch( attributeValue );
+ const text = await response.text();
+ headElements.set( attributeValue, {
+ tag,
+ text,
+ } );
+ } catch ( e ) {
+ // eslint-disable-next-line no-console
+ console.error( e );
+ }
+ }
+
+ const headElement = headElements.get( attributeValue );
+ const element = doc.createElement( tagName );
+ element.innerText = headElement.text;
+ for ( const attr of headElement.tag.attributes ) {
+ element.setAttribute( attr.name, attr.value );
+ }
+ headTags.push( element );
+ } )
+ );
+ }
+
+ return [
+ doc.querySelector( 'title' ),
+ ...doc.querySelectorAll( 'style' ),
+ ...headTags,
+ ];
+};
diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js
index 03d399338167c..03d75bafa82f4 100644
--- a/packages/interactivity-router/src/index.js
+++ b/packages/interactivity-router/src/index.js
@@ -3,6 +3,11 @@
*/
import { store, privateApis, getConfig } from '@wordpress/interactivity';
+/**
+ * Internal dependencies
+ */
+import { fetchHeadAssets, updateHead } from './head';
+
const {
directivePrefix,
getRegionRootFragment,
@@ -16,8 +21,13 @@ const {
'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.'
);
-// The cache of visited and prefetched pages.
+// Check if the navigation mode is full page or region based.
+const navigationMode =
+ getConfig( 'core/router' ).navigationMode ?? 'regionBased';
+
+// The cache of visited and prefetched pages, stylesheets and scripts.
const pages = new Map();
+const headElements = new Map();
// Helper to remove domain and hash from the URL. We are only interesting in
// caching the path and the query.
@@ -43,30 +53,53 @@ const fetchPage = async ( url, { html } ) => {
// Return an object with VDOM trees of those HTML regions marked with a
// `router-region` directive.
-const regionsToVdom = ( dom, { vdom } = {} ) => {
+const regionsToVdom = async ( dom, { vdom } = {} ) => {
const regions = {};
- const attrName = `data-${ directivePrefix }-router-region`;
- dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
- const id = region.getAttribute( attrName );
- regions[ id ] = vdom?.has( region )
- ? vdom.get( region )
- : toVdom( region );
- } );
+ let head;
+ if ( process.env.IS_GUTENBERG_PLUGIN ) {
+ if ( navigationMode === 'fullPage' ) {
+ head = await fetchHeadAssets( dom, headElements );
+ regions.body = vdom
+ ? vdom.get( document.body )
+ : toVdom( dom.body );
+ }
+ }
+ if ( navigationMode === 'regionBased' ) {
+ const attrName = `data-${ directivePrefix }-router-region`;
+ dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
+ const id = region.getAttribute( attrName );
+ regions[ id ] = vdom?.has( region )
+ ? vdom.get( region )
+ : toVdom( region );
+ } );
+ }
const title = dom.querySelector( 'title' )?.innerText;
const initialData = parseInitialData( dom );
- return { regions, title, initialData };
+ return { regions, head, title, initialData };
};
// Render all interactive regions contained in the given page.
const renderRegions = ( page ) => {
batch( () => {
- populateInitialData( page.initialData );
- const attrName = `data-${ directivePrefix }-router-region`;
- document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
- const id = region.getAttribute( attrName );
- const fragment = getRegionRootFragment( region );
- render( page.regions[ id ], fragment );
- } );
+ if ( process.env.IS_GUTENBERG_PLUGIN ) {
+ if ( navigationMode === 'fullPage' ) {
+ // Once this code is tested and more mature, the head should be updated for region based navigation as well.
+ updateHead( page.head );
+ const fragment = getRegionRootFragment( document.body );
+ render( page.regions.body, fragment );
+ }
+ }
+ if ( navigationMode === 'regionBased' ) {
+ populateInitialData( page.initialData );
+ const attrName = `data-${ directivePrefix }-router-region`;
+ document
+ .querySelectorAll( `[${ attrName }]` )
+ .forEach( ( region ) => {
+ const id = region.getAttribute( attrName );
+ const fragment = getRegionRootFragment( region );
+ render( page.regions[ id ], fragment );
+ } );
+ }
if ( page.title ) {
document.title = page.title;
}
@@ -102,12 +135,47 @@ window.addEventListener( 'popstate', async () => {
}
} );
-// Cache the initial page using the intially parsed vDOM.
+// Initialize the router and cache the initial page using the initial vDOM.
+// Once this code is tested and more mature, the head should be updated for region based navigation as well.
+if ( process.env.IS_GUTENBERG_PLUGIN ) {
+ if ( navigationMode === 'fullPage' ) {
+ // Cache the scripts. Has to be called before fetching the assets.
+ [].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => {
+ headElements.set( script.getAttribute( 'src' ), {
+ tag: script,
+ text: script.textContent,
+ } );
+ } );
+ await fetchHeadAssets( document, headElements );
+ }
+}
pages.set(
getPagePath( window.location ),
Promise.resolve( regionsToVdom( document, { vdom: initialVdom } ) )
);
+// Check if the link is valid for client-side navigation.
+const isValidLink = ( ref ) =>
+ ref &&
+ ref instanceof window.HTMLAnchorElement &&
+ ref.href &&
+ ( ! ref.target || ref.target === '_self' ) &&
+ ref.origin === window.location.origin &&
+ ! ref.pathname.startsWith( '/wp-admin' ) &&
+ ! ref.pathname.startsWith( '/wp-login.php' ) &&
+ ! ref.getAttribute( 'href' ).startsWith( '#' ) &&
+ ! new URL( ref.href ).searchParams.has( '_wpnonce' );
+
+// Check if the event is valid for client-side navigation.
+const isValidEvent = ( event ) =>
+ event &&
+ event.button === 0 && // Left clicks only.
+ ! event.metaKey && // Open in new tab (Mac).
+ ! event.ctrlKey && // Open in new tab (Windows).
+ ! event.altKey && // Download.
+ ! event.shiftKey &&
+ ! event.defaultPrevented;
+
// Variable to store the current navigation.
let navigatingTo = '';
@@ -193,7 +261,7 @@ export const { state, actions } = store( 'core/router', {
! page.initialData?.config?.[ 'core/router' ]
?.clientNavigationDisabled
) {
- renderRegions( page );
+ yield renderRegions( page );
window.history[
options.replace ? 'replaceState' : 'pushState'
]( {}, '', href );
@@ -218,6 +286,10 @@ export const { state, actions } = store( 'core/router', {
? '\u00A0'
: '' );
}
+
+ // Scroll to the anchor if exits in the link.
+ const { hash } = new URL( href, window.location );
+ if ( hash ) document.querySelector( hash )?.scrollIntoView();
} else {
yield forcePageReload( href );
}
@@ -232,8 +304,7 @@ export const { state, actions } = store( 'core/router', {
* @param {string} url The page URL.
* @param {Object} [options] Options object.
* @param {boolean} [options.force] Force fetching the URL again.
- * @param {string} [options.html] HTML string to be used instead of
- * fetching the requested URL.
+ * @param {string} [options.html] HTML string to be used instead of fetching the requested URL.
*/
prefetch( url, options = {} ) {
const { clientNavigationDisabled } = getConfig();
@@ -246,3 +317,34 @@ export const { state, actions } = store( 'core/router', {
},
},
} );
+
+// Add click and prefetch to all links.
+if ( process.env.IS_GUTENBERG_PLUGIN ) {
+ if ( navigationMode === 'fullPage' ) {
+ // Navigate on click.
+ document.addEventListener(
+ 'click',
+ function ( event ) {
+ const ref = event.target.closest( 'a' );
+ if ( isValidLink( ref ) && isValidEvent( event ) ) {
+ event.preventDefault();
+ actions.navigate( ref.href );
+ }
+ },
+ true
+ );
+ // Prefetch on hover.
+ document.addEventListener(
+ 'mouseenter',
+ function ( event ) {
+ if ( event.target?.nodeName === 'A' ) {
+ const ref = event.target.closest( 'a' );
+ if ( isValidLink( ref ) && isValidEvent( event ) ) {
+ actions.prefetch( ref.href );
+ }
+ }
+ },
+ true
+ );
+ }
+}