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 + ); + } +}