From 6a1b29aadceceaabc284353b6f6469d2588970eb Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 5 Mar 2024 15:31:39 +0100 Subject: [PATCH 01/61] Add Gutenberg experiment option --- lib/experiments-page.php | 12 ++++++++++++ lib/load.php | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/lib/experiments-page.php b/lib/experiments-page.php index f66e0932192637..71f65622831d93 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-client-side-navigation', + __( 'Enable full client-side navigation', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Enable full client-side navigation using the Interactivity API', 'gutenberg' ), + 'id' => 'gutenberg-full-client-side-navigation', + ) + ); + register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/lib/load.php b/lib/load.php index 908f3fd6f36d44..ed0e8ff24108a6 100644 --- a/lib/load.php +++ b/lib/load.php @@ -138,6 +138,10 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/disable-tinymce.php'; } +if ( gutenberg_is_experiment_enabled( 'gutenberg-full-client-side-navigation' ) ) { + require __DIR__ . '/experimental/full-client-side-navigation.php'; +} + // Fonts API / Font Face. remove_action( 'plugins_loaded', '_wp_theme_json_webfonts_handler' ); // Turns off WordPress 6.0's stopgap handler. From a5b6dd06a77c7831f5001c89575733bc131241d9 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 5 Mar 2024 15:33:35 +0100 Subject: [PATCH 02/61] Add config option and directives in PHP --- .../full-client-side-navigation.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 lib/experimental/full-client-side-navigation.php diff --git a/lib/experimental/full-client-side-navigation.php b/lib/experimental/full-client-side-navigation.php new file mode 100644 index 00000000000000..dba7bb0113df0d --- /dev/null +++ b/lib/experimental/full-client-side-navigation.php @@ -0,0 +1,19 @@ + true ) ); + +// Add directives to all links. +function gutenberg_add_client_side_navigation_directives( $content ) { + $p = new WP_HTML_Tag_Processor( $content ); + while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) { + $p->set_attribute( 'data-wp-on--click', 'core/router/experimental::actions.navigate' ); + } + // Add `` hack until we figure out a better way to add a global `data-wp-interactive`. + return (string) $p . ''; +} + +add_filter( 'render_block', 'gutenberg_add_client_side_navigation_directives', 10, 1 ); From cf8e72bdbcf072869946ce2657cacbb2bfb4155c Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 5 Mar 2024 15:38:12 +0100 Subject: [PATCH 03/61] Load full CSN logic conditionally --- .../interactivity/src/experiments/full-csn.js | 204 ++++++++++++++++++ packages/interactivity/src/index.ts | 6 +- 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 packages/interactivity/src/experiments/full-csn.js diff --git a/packages/interactivity/src/experiments/full-csn.js b/packages/interactivity/src/experiments/full-csn.js new file mode 100644 index 00000000000000..8fcd9d878a8cc1 --- /dev/null +++ b/packages/interactivity/src/experiments/full-csn.js @@ -0,0 +1,204 @@ +/** + * External dependencies + */ +import { hydrate, render } from 'preact'; + +/** + * Internal dependencies + */ +import { directivePrefix } from '../constants'; +import { store, getConfig } from '../store'; +import { getElement } from '../hooks'; +import { createRootFragment } from '../utils'; +import { toVdom, hydratedIslands } from '../vdom'; + +// The root to render the vdom (document.body). +let rootFragment; + +// The cache of visited and prefetched pages, stylesheets and scripts. +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. +const cleanUrl = ( url ) => { + const u = new URL( url, window.location ); + return u.pathname + u.search; +}; + +// Helper to check if a page can do client-side navigation. +const canDoClientSideNavigation = () => + getConfig( 'core/router/experimental' ).fullClientSideNavigation; + +/** + * 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 ); + } ) + ); + + 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() ); + if ( ! canDoClientSideNavigation() ) return false; + const dom = new window.DOMParser().parseFromString( html, 'text/html' ); + const head = await fetchAssets( dom ); + return { head, body: toVdom( dom.body ) }; +}; + +const isValidLink = ( ref ) => + ref && + ref instanceof window.HTMLAnchorElement && + ref.href && + ( ! ref.target || ref.target === '_self' ) && + ref.origin === window.location.origin; + +const isValidEvent = ( 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; + +const { actions } = store( 'core/router/experimental', { + actions: { + *navigate( event ) { + const { ref } = getElement(); + if ( isValidLink( ref ) && isValidEvent( event ) ) { + const { href } = ref; + event.preventDefault(); + const url = cleanUrl( href ); + yield actions.prefetch( url ); + const page = yield pages.get( url ); + if ( page ) { + document.head.replaceChildren( ...page.head ); + render( page.body, rootFragment ); + window.history.pushState( {}, '', href ); + } else { + window.location.assign( href ); + } + } + }, + prefetch( url ) { + if ( ! canDoClientSideNavigation() ) return; + + url = cleanUrl( url ); + if ( ! pages.has( url ) ) { + pages.set( url, fetchPage( url ) ); + } + }, + }, +} ); + +// Listen to the back and forward buttons and restore the page if it's in the +// cache. +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 ); + } else { + window.location.reload(); + } +} ); + +// Initialize the router with the initial DOM. + +if ( canDoClientSideNavigation() ) { + // 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 ); + } ); + + 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 ); + } + } ); +} diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 3c91e919d91bdc..1ffee708f19505 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -13,7 +13,7 @@ import { init, getRegionRootFragment, initialVdom } from './init'; import { directivePrefix } from './constants'; import { toVdom } from './vdom'; import { directive, getNamespace } from './hooks'; -import { parseInitialData, populateInitialData } from './store'; +import { getConfig, parseInitialData, populateInitialData } from './store'; export { store, getConfig } from './store'; export { getContext, getElement } from './hooks'; @@ -58,3 +58,7 @@ document.addEventListener( 'DOMContentLoaded', async () => { registerDirectives(); await init(); } ); + +if ( getConfig( 'core/router/experimental' ) ) { + import( './experiments/full-csn' ); +} From 8ddc122de08d4c0a50eb272cf3e9557c03b0f548 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 5 Mar 2024 16:54:16 +0100 Subject: [PATCH 04/61] Add `data-wp-interactive` root --- .../full-client-side-navigation.php | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/lib/experimental/full-client-side-navigation.php b/lib/experimental/full-client-side-navigation.php index dba7bb0113df0d..4c1d774faf2823 100644 --- a/lib/experimental/full-client-side-navigation.php +++ b/lib/experimental/full-client-side-navigation.php @@ -12,8 +12,50 @@ function gutenberg_add_client_side_navigation_directives( $content ) { while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) { $p->set_attribute( 'data-wp-on--click', 'core/router/experimental::actions.navigate' ); } - // Add `` hack until we figure out a better way to add a global `data-wp-interactive`. - return (string) $p . ''; + return (string) $p; } add_filter( 'render_block', 'gutenberg_add_client_side_navigation_directives', 10, 1 ); + +// Add `data-wp-interactive` to the top level tag. +function gutenberg_interactivity_add_directives_csn( array $parsed_block ): array { + static $root_interactive_block = null; + + /* + * Checks whether a root interactive block is already annotated for + * processing, and if it is, it ignores the subsequent ones. + */ + if ( null === $root_interactive_block ) { + $block_name = $parsed_block['blockName']; + if ( isset( $block_name ) ) { + // Annotates the root interactive block for processing. + $root_interactive_block = array( $block_name, $parsed_block ); + + /* + * Adds a filter to process the root interactive block once it has + * finished rendering. + */ + $process_interactive_blocks = static function ( string $content, array $parsed_block ) use ( &$root_interactive_block, &$process_interactive_blocks ): string { + // Checks whether the current block is the root block. + list($root_block_name, $root_parsed_block) = $root_interactive_block; + if ( $root_block_name === $parsed_block['blockName'] && $parsed_block === $root_parsed_block ) { + // The root interactive blocks has finished rendering, process it. + $p = new WP_HTML_Tag_Processor( $content ); + if ( $p->next_tag() ) { + $p->set_attribute( 'data-wp-interactive', 'core/experimental' ); + } + $content = (string) $p; + // Removes the filter and reset the root interactive block. + remove_filter( 'render_block_' . $parsed_block['blockName'], $process_interactive_blocks ); + $root_interactive_block = null; + } + return $content; + }; + + add_filter( 'render_block_' . $block_name, $process_interactive_blocks, 20, 2 ); + } + } + + return $parsed_block; +} +add_filter( 'render_block_data', 'gutenberg_interactivity_add_directives_csn' ); From 32cd62c7dd2ac5dbffecb003bf171042f608ee60 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 5 Mar 2024 16:58:45 +0100 Subject: [PATCH 05/61] Change variables names --- .../full-client-side-navigation.php | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/experimental/full-client-side-navigation.php b/lib/experimental/full-client-side-navigation.php index 4c1d774faf2823..870083af05c0ee 100644 --- a/lib/experimental/full-client-side-navigation.php +++ b/lib/experimental/full-client-side-navigation.php @@ -19,40 +19,40 @@ function gutenberg_add_client_side_navigation_directives( $content ) { // Add `data-wp-interactive` to the top level tag. function gutenberg_interactivity_add_directives_csn( array $parsed_block ): array { - static $root_interactive_block = null; + static $root_block = null; /* - * Checks whether a root interactive block is already annotated for + * Checks whether a root block is already annotated for * processing, and if it is, it ignores the subsequent ones. */ - if ( null === $root_interactive_block ) { + if ( null === $root_block ) { $block_name = $parsed_block['blockName']; if ( isset( $block_name ) ) { - // Annotates the root interactive block for processing. - $root_interactive_block = array( $block_name, $parsed_block ); + // Annotates the root block for processing. + $root_block = array( $block_name, $parsed_block ); /* * Adds a filter to process the root interactive block once it has * finished rendering. */ - $process_interactive_blocks = static function ( string $content, array $parsed_block ) use ( &$root_interactive_block, &$process_interactive_blocks ): string { + $add_directive_to_root_block = static function ( string $content, array $parsed_block ) use ( &$root_block, &$add_directive_to_root_block ): string { // Checks whether the current block is the root block. - list($root_block_name, $root_parsed_block) = $root_interactive_block; + list($root_block_name, $root_parsed_block) = $root_block; if ( $root_block_name === $parsed_block['blockName'] && $parsed_block === $root_parsed_block ) { - // The root interactive blocks has finished rendering, process it. + // The root block has finished rendering, process it. $p = new WP_HTML_Tag_Processor( $content ); if ( $p->next_tag() ) { $p->set_attribute( 'data-wp-interactive', 'core/experimental' ); } $content = (string) $p; - // Removes the filter and reset the root interactive block. - remove_filter( 'render_block_' . $parsed_block['blockName'], $process_interactive_blocks ); - $root_interactive_block = null; + // Removes the filter and reset the root block. + remove_filter( 'render_block_' . $parsed_block['blockName'], $add_directive_to_root_block ); + $root_block = null; } return $content; }; - add_filter( 'render_block_' . $block_name, $process_interactive_blocks, 20, 2 ); + add_filter( 'render_block_' . $block_name, $add_directive_to_root_block, 20, 2 ); } } From cc25473ea7499bc8681d79461adcad7e6d2be027 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 6 Mar 2024 16:00:04 +0100 Subject: [PATCH 06/61] Register different scripts if the experiment is enabled --- lib/interactivity-api.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/interactivity-api.php b/lib/interactivity-api.php index da349e1c033760..804590a8795c06 100644 --- a/lib/interactivity-api.php +++ b/lib/interactivity-api.php @@ -21,12 +21,22 @@ function gutenberg_reregister_interactivity_script_modules() { $default_version ); - wp_register_script_module( - '@wordpress/interactivity-router', - gutenberg_url( '/build/interactivity/router.min.js' ), - array( '@wordpress/interactivity' ), - $default_version - ); + if ( gutenberg_is_experiment_enabled( 'gutenberg-full-client-side-navigation' ) ) { + wp_register_script_module( + '@wordpress/interactivity-router', + gutenberg_url( '/build/interactivity/full-csn.min.js' ), + array( '@wordpress/interactivity' ), + $default_version + ); + wp_enqueue_script_module( '@wordpress/interactivity-router' ); + } else { + wp_register_script_module( + '@wordpress/interactivity-router', + gutenberg_url( '/build/interactivity/router.min.js' ), + array( '@wordpress/interactivity' ), + $default_version + ); + } } add_action( 'init', 'gutenberg_reregister_interactivity_script_modules' ); From dffa452c4e6d8d2ef2d14ef586d53d6ec7043446 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 6 Mar 2024 16:00:30 +0100 Subject: [PATCH 07/61] Require experimental code once interactivity is loaded --- lib/load.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/load.php b/lib/load.php index ed0e8ff24108a6..a0a9745adcc13d 100644 --- a/lib/load.php +++ b/lib/load.php @@ -138,10 +138,6 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/disable-tinymce.php'; } -if ( gutenberg_is_experiment_enabled( 'gutenberg-full-client-side-navigation' ) ) { - require __DIR__ . '/experimental/full-client-side-navigation.php'; -} - // Fonts API / Font Face. remove_action( 'plugins_loaded', '_wp_theme_json_webfonts_handler' ); // Turns off WordPress 6.0's stopgap handler. @@ -196,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-client-side-navigation' ) ) { + require __DIR__ . '/experimental/full-client-side-navigation.php'; +} // Copied package PHP files. if ( is_dir( __DIR__ . '/../build/style-engine' ) ) { From 7412f98894e9123952cc4c727722f403bd9027d9 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 6 Mar 2024 16:00:47 +0100 Subject: [PATCH 08/61] Change experiment namespace --- lib/experimental/full-client-side-navigation.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/experimental/full-client-side-navigation.php b/lib/experimental/full-client-side-navigation.php index 870083af05c0ee..6794973942698d 100644 --- a/lib/experimental/full-client-side-navigation.php +++ b/lib/experimental/full-client-side-navigation.php @@ -4,13 +4,13 @@ */ // Add the full client-side navigation config option. -wp_interactivity_config( 'core/router/experimental', array( 'fullClientSideNavigation' => true ) ); +wp_interactivity_config( 'core/router', array( 'fullClientSideNavigation' => true ) ); // Add directives to all links. function gutenberg_add_client_side_navigation_directives( $content ) { $p = new WP_HTML_Tag_Processor( $content ); while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) { - $p->set_attribute( 'data-wp-on--click', 'core/router/experimental::actions.navigate' ); + $p->set_attribute( 'data-wp-on--click', 'core/router::actions.navigate' ); } return (string) $p; } From f92355d272ec71c7e1b86f24436f1296aa5a9fef Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 6 Mar 2024 16:06:06 +0100 Subject: [PATCH 09/61] Move full-csn logic to interactivity-router --- .../src}/full-csn.js | 64 ++++++++----------- packages/interactivity/src/index.ts | 8 +-- tools/webpack/interactivity.js | 1 + 3 files changed, 30 insertions(+), 43 deletions(-) rename packages/{interactivity/src/experiments => interactivity-router/src}/full-csn.js (77%) diff --git a/packages/interactivity/src/experiments/full-csn.js b/packages/interactivity-router/src/full-csn.js similarity index 77% rename from packages/interactivity/src/experiments/full-csn.js rename to packages/interactivity-router/src/full-csn.js index 8fcd9d878a8cc1..12ce134102802c 100644 --- a/packages/interactivity/src/experiments/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -1,16 +1,16 @@ /** - * External dependencies + * WordPress dependencies */ -import { hydrate, render } from 'preact'; +import { + privateApis, + store, + getConfig, + getElement, +} from '@wordpress/interactivity'; -/** - * Internal dependencies - */ -import { directivePrefix } from '../constants'; -import { store, getConfig } from '../store'; -import { getElement } from '../hooks'; -import { createRootFragment } from '../utils'; -import { toVdom, hydratedIslands } from '../vdom'; +const { createRootFragment, render, toVdom } = privateApis( + 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.' +); // The root to render the vdom (document.body). let rootFragment; @@ -29,7 +29,7 @@ const cleanUrl = ( url ) => { // Helper to check if a page can do client-side navigation. const canDoClientSideNavigation = () => - getConfig( 'core/router/experimental' ).fullClientSideNavigation; + getConfig( 'core/router' ).fullClientSideNavigation; /** * Finds the elements in the document that match the selector and fetch them. @@ -130,16 +130,18 @@ const isValidEvent = ( event ) => ! event.shiftKey && ! event.defaultPrevented; -const { actions } = store( 'core/router/experimental', { +const { actions } = store( 'core/router', { actions: { - *navigate( event ) { + *navigate( event, url ) { const { ref } = getElement(); - if ( isValidLink( ref ) && isValidEvent( event ) ) { - const { href } = ref; + + if ( url || ( isValidLink( ref ) && isValidEvent( event ) ) ) { + const href = url ? url : ref.href; event.preventDefault(); - const url = cleanUrl( href ); - yield actions.prefetch( url ); - const page = yield pages.get( url ); + const newUrl = cleanUrl( href ); + yield actions.prefetch( newUrl ); + const page = yield pages.get( newUrl ); + if ( page ) { document.head.replaceChildren( ...page.head ); render( page.body, rootFragment ); @@ -176,29 +178,15 @@ window.addEventListener( 'popstate', async () => { // Initialize the router with the initial DOM. if ( canDoClientSideNavigation() ) { - // 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 ); - } ); - - 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 ); - } - } ); + // const body = toVdom( document.body ); + // [].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => { + // scripts.set( script.getAttribute( 'src' ), script.textContent ); + // } ); + // const head = await fetchAssets( document ); + // pages.set( cleanUrl( window.location ), Promise.resolve( { body, head } ) ); } diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 1ffee708f19505..33b0fb6f585ced 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -13,7 +13,8 @@ import { init, getRegionRootFragment, initialVdom } from './init'; import { directivePrefix } from './constants'; import { toVdom } from './vdom'; import { directive, getNamespace } from './hooks'; -import { getConfig, parseInitialData, populateInitialData } from './store'; +import { parseInitialData, populateInitialData } from './store'; +import { createRootFragment } from './utils'; export { store, getConfig } from './store'; export { getContext, getElement } from './hooks'; @@ -37,6 +38,7 @@ export const privateApis = ( lock ): any => { return { directivePrefix, getRegionRootFragment, + createRootFragment, initialVdom, toVdom, directive, @@ -58,7 +60,3 @@ document.addEventListener( 'DOMContentLoaded', async () => { registerDirectives(); await init(); } ); - -if ( getConfig( 'core/router/experimental' ) ) { - import( './experiments/full-csn' ); -} diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index 2aadcbf79a158e..2d8515cf7be244 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -19,6 +19,7 @@ module.exports = { entry: { index: './packages/interactivity', router: './packages/interactivity-router', + 'full-csn': './packages/interactivity-router/src/full-csn.js', navigation: './packages/block-library/src/navigation/view.js', query: './packages/block-library/src/query/view.js', image: './packages/block-library/src/image/view.js', From 04b94a949d1586d43a904502c33ade07880e45c5 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 6 Mar 2024 16:26:13 +0100 Subject: [PATCH 10/61] Add proper support for prefetch --- lib/experimental/full-client-side-navigation.php | 1 + packages/interactivity-router/src/full-csn.js | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/experimental/full-client-side-navigation.php b/lib/experimental/full-client-side-navigation.php index 6794973942698d..24f3f04a61cdce 100644 --- a/lib/experimental/full-client-side-navigation.php +++ b/lib/experimental/full-client-side-navigation.php @@ -11,6 +11,7 @@ function gutenberg_add_client_side_navigation_directives( $content ) { $p = new WP_HTML_Tag_Processor( $content ); while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) { $p->set_attribute( 'data-wp-on--click', 'core/router::actions.navigate' ); + $p->set_attribute( 'data-wp-on--mouseover', 'core/router::actions.prefetch' ); } return (string) $p; } diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index 12ce134102802c..3e01303d430831 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -139,7 +139,7 @@ const { actions } = store( 'core/router', { const href = url ? url : ref.href; event.preventDefault(); const newUrl = cleanUrl( href ); - yield actions.prefetch( newUrl ); + yield actions.prefetch( event, newUrl ); const page = yield pages.get( newUrl ); if ( page ) { @@ -151,12 +151,14 @@ const { actions } = store( 'core/router', { } } }, - prefetch( url ) { + prefetch( event, url ) { if ( ! canDoClientSideNavigation() ) return; + const { ref } = getElement(); + const href = url ? url : ref.href; - url = cleanUrl( url ); - if ( ! pages.has( url ) ) { - pages.set( url, fetchPage( url ) ); + const newUrl = cleanUrl( href ); + if ( ! pages.has( newUrl ) ) { + pages.set( newUrl, fetchPage( newUrl ) ); } }, }, From ca90d0a2c66a7da7b63d529650a4f98f3e6da712 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 6 Mar 2024 17:15:41 +0100 Subject: [PATCH 11/61] Adapt query loop --- packages/block-library/src/query/view.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index e23294a24e02e3..191969c18b9658 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -43,25 +43,21 @@ store( queryRef.querySelector( firstAnchor )?.focus(); } }, - *prefetch() { + *prefetch( event ) { const { ref } = getElement(); if ( isValidLink( ref ) ) { - const { actions } = yield import( - '@wordpress/interactivity-router' - ); - yield actions.prefetch( ref.href ); + yield import( '@wordpress/interactivity-router' ); + store( 'core/router' ).actions.prefetch( event, ref.href ); } }, }, callbacks: { - *prefetch() { + *prefetch( event ) { const { url } = getContext(); const { ref } = getElement(); if ( url && isValidLink( ref ) ) { - const { actions } = yield import( - '@wordpress/interactivity-router' - ); - yield actions.prefetch( ref.href ); + yield import( '@wordpress/interactivity-router' ); + store( 'core/router' ).actions.prefetch( event, ref.href ); } }, }, From 34e6b2203a21ed2cf321fe290f8692c6e65c3644 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 6 Mar 2024 17:21:11 +0100 Subject: [PATCH 12/61] Fix modules error after csn --- packages/interactivity-router/src/full-csn.js | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index 3e01303d430831..e0bdef7ead095f 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -19,6 +19,7 @@ let rootFragment; const pages = new Map(); const stylesheets = new Map(); const scripts = new Map(); +const modules = new Map(); // Helper to remove domain and hash from the URL. We are only interesting in // caching the path and the query. @@ -82,24 +83,29 @@ const fetchAssets = async ( document ) => { ); const scriptTags = await fetchScriptOrStyle( document, - 'script[src]', + 'script:not([type="module"])[src]', 'src', scripts, 'script' ); - const moduleScripts = await fetchScriptOrStyle( - document, - 'script[type=module]', - 'src', - scripts, - 'script' - ); - moduleScripts.forEach( ( script ) => - script.setAttribute( 'type', 'module' ) + const moduleScripts = await Promise.all( + [].map.call( + document.querySelectorAll( 'script[type=module]' ), + ( el ) => { + const attributeValue = el.getAttribute( 'src' ); + if ( ! modules.has( attributeValue ) ) + fetch( attributeValue ).then( ( r ) => + r.text( modules.set( attributeValue, el ) ) + ); + + return modules.get( attributeValue ); + } + ) ); return [ ...scriptTags, + ...moduleScripts, document.querySelector( 'title' ), ...document.querySelectorAll( 'style' ), ...stylesFromSheets, @@ -186,9 +192,12 @@ if ( canDoClientSideNavigation() ) { ); // Cache the scripts. Has to be called before fetching the assets. // const body = toVdom( document.body ); - // [].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => { - // scripts.set( script.getAttribute( 'src' ), script.textContent ); - // } ); + [].map.call( + document.querySelectorAll( 'script[type=module]' ), + ( script ) => { + scripts.set( script.getAttribute( 'src' ), script ); + } + ); // const head = await fetchAssets( document ); // pages.set( cleanUrl( window.location ), Promise.resolve( { body, head } ) ); } From 4f67b70073104a47062b65e24e1e485286e27033 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 6 Mar 2024 18:43:04 +0100 Subject: [PATCH 13/61] Add initial page to cache --- packages/interactivity-router/src/full-csn.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index e0bdef7ead095f..5a862dc8d14eb8 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -191,13 +191,15 @@ if ( canDoClientSideNavigation() ) { document.body ); // Cache the scripts. Has to be called before fetching the assets. - // const body = toVdom( document.body ); [].map.call( document.querySelectorAll( 'script[type=module]' ), ( script ) => { scripts.set( script.getAttribute( 'src' ), script ); } ); - // const head = await fetchAssets( document ); - // pages.set( cleanUrl( window.location ), Promise.resolve( { body, head } ) ); + const head = await fetchAssets( document ); + pages.set( + cleanUrl( window.location ), + Promise.resolve( { head, body: toVdom( document.body ) } ) + ); } From 592b842ab9a39395193d5bdce6d970b060840acb Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Thu, 7 Mar 2024 14:39:58 +0100 Subject: [PATCH 14/61] WIP: Fix scripts loading after csn --- packages/interactivity-router/src/full-csn.js | 173 +++++++++++++----- 1 file changed, 123 insertions(+), 50 deletions(-) diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index 5a862dc8d14eb8..c97fbdd3ead9cf 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -19,7 +19,6 @@ let rootFragment; const pages = new Map(); const stylesheets = new Map(); const scripts = new Map(); -const modules = new Map(); // Helper to remove domain and hash from the URL. We are only interesting in // caching the path and the query. @@ -46,66 +45,137 @@ const canDoClientSideNavigation = () => * 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 ); - } ) - ); - return fetchedItems.map( ( item ) => { - const element = document.createElement( elementToCreate ); - element.textContent = item; - return element; +const getTagId = ( tag ) => tag.id || tag.outerHTML; + +const canBePreloaded = ( e ) => e.src || ( e.href && e.rel !== 'preload' ); + +const loadAsset = ( a ) => { + const loader = document.createElement( 'link' ); + loader.rel = 'preload'; + if ( a.nodeName === 'SCRIPT' ) { + loader.as = 'script'; + loader.href = a.getAttribute( 'src' ); + } else if ( a.nodeName === 'LINK' ) { + loader.as = 'style'; + loader.href = a.getAttribute( 'href' ); + } + + const p = new Promise( ( resolve, reject ) => { + loader.onload = () => resolve( loader ); + loader.onerror = () => reject( loader ); } ); + + document.head.appendChild( loader ); + return p; }; -// 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:not([type="module"])[src]', - 'src', - scripts, - 'script' +const activateScript = ( n ) => { + if ( + n.nodeName !== 'SCRIPT' && + n.nodeName !== 'STYLE' && + n.nodeName !== 'LINK' + ) + return n; + const s = document.createElement( n.nodeName ); + s.innerText = n.innerText; + for ( const attr of n.attributes ) { + s.setAttribute( attr.name, attr.value ); + } + return s; +}; + +const updateHead = async ( newHead ) => { + // 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() ]; + + // Wait for all new assets to be loaded. + const loaders = await Promise.all( + toAppend.filter( canBePreloaded ).map( loadAsset ) ); - const moduleScripts = await Promise.all( + + // Apply the changes. + toRemove.forEach( ( n ) => n.remove() ); + loaders.forEach( ( l ) => l && l.remove() ); + document.head.append( ...toAppend.map( activateScript ) ); +}; + +const nextTick = ( fn ) => + new Promise( ( resolve ) => setTimeout( () => resolve( fn() ) ) ); + +const fetchStyle = async ( document ) => { + const fetchedItems = await Promise.all( [].map.call( - document.querySelectorAll( 'script[type=module]' ), + document.querySelectorAll( 'link[rel=stylesheet]' ), ( el ) => { - const attributeValue = el.getAttribute( 'src' ); - if ( ! modules.has( attributeValue ) ) - fetch( attributeValue ).then( ( r ) => - r.text( modules.set( attributeValue, el ) ) + const attributeValue = el.getAttribute( 'href' ); + if ( ! stylesheets.has( attributeValue ) ) + stylesheets.set( + attributeValue, + fetch( attributeValue ).then( ( r ) => r.text() ) ); - - return modules.get( attributeValue ); + return stylesheets.get( attributeValue ); } ) ); + return fetchedItems.map( ( item ) => { + const element = document.createElement( 'style' ); + element.textContent = item; + return element; + } ); +}; + +const fetchScript = async ( document ) => { + const fetchedItems = await Promise.all( + [].map.call( document.querySelectorAll( 'script[src]' ), ( el ) => { + const attributeValue = el.getAttribute( 'src' ); + if ( ! scripts.has( attributeValue ) ) + scripts.set( attributeValue, { + el, + text: fetch( attributeValue ).then( ( r ) => r.text() ), + } ); + return scripts.get( attributeValue ); + } ) + ); + + return fetchedItems.map( ( item ) => { + const element = document.createElement( 'script' ); + element.innerText = item.el.innerText; + for ( const attr of item.el.attributes ) { + element.setAttribute( attr.name, attr.value ); + } + + return element; + } ); +}; + +// Fetch styles of a new page. +const fetchAssets = async ( document ) => { + const stylesFromSheets = await fetchStyle( document ); + const scriptTags = await fetchScript( document ); + return [ ...scriptTags, - ...moduleScripts, document.querySelector( 'title' ), ...document.querySelectorAll( 'style' ), ...stylesFromSheets, @@ -149,8 +219,8 @@ const { actions } = store( 'core/router', { const page = yield pages.get( newUrl ); if ( page ) { - document.head.replaceChildren( ...page.head ); - render( page.body, rootFragment ); + yield updateHead( page.head ); + yield nextTick( () => render( page.body, rootFragment ) ); window.history.pushState( {}, '', href ); } else { window.location.assign( href ); @@ -194,7 +264,10 @@ if ( canDoClientSideNavigation() ) { [].map.call( document.querySelectorAll( 'script[type=module]' ), ( script ) => { - scripts.set( script.getAttribute( 'src' ), script ); + scripts.set( script.getAttribute( 'src' ), { + el: script, + text: script.textContent, + } ); } ); const head = await fetchAssets( document ); From 1593483ae9426cf98b2e1f5c4123ad01c86c9294 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Thu, 7 Mar 2024 16:05:32 +0100 Subject: [PATCH 15/61] Simplify code --- packages/interactivity-router/src/full-csn.js | 125 +++++++----------- 1 file changed, 46 insertions(+), 79 deletions(-) diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index c97fbdd3ead9cf..eaa7f208cdab65 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -17,8 +17,7 @@ let rootFragment; // The cache of visited and prefetched pages, stylesheets and scripts. const pages = new Map(); -const stylesheets = new Map(); -const scripts = 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. @@ -70,21 +69,6 @@ const loadAsset = ( a ) => { return p; }; -const activateScript = ( n ) => { - if ( - n.nodeName !== 'SCRIPT' && - n.nodeName !== 'STYLE' && - n.nodeName !== 'LINK' - ) - return n; - const s = document.createElement( n.nodeName ); - s.innerText = n.innerText; - for ( const attr of n.attributes ) { - s.setAttribute( attr.name, attr.value ); - } - return s; -}; - const updateHead = async ( newHead ) => { // Map incoming head tags by their content. const newHeadMap = new Map(); @@ -116,69 +100,55 @@ const updateHead = async ( newHead ) => { // Apply the changes. toRemove.forEach( ( n ) => n.remove() ); loaders.forEach( ( l ) => l && l.remove() ); - document.head.append( ...toAppend.map( activateScript ) ); + document.head.append( ...toAppend ); }; const nextTick = ( fn ) => new Promise( ( resolve ) => setTimeout( () => resolve( fn() ) ) ); -const fetchStyle = async ( document ) => { - const fetchedItems = await Promise.all( - [].map.call( - document.querySelectorAll( 'link[rel=stylesheet]' ), - ( el ) => { - const attributeValue = el.getAttribute( 'href' ); - if ( ! stylesheets.has( attributeValue ) ) - stylesheets.set( - attributeValue, - fetch( attributeValue ).then( ( r ) => r.text() ) - ); - return stylesheets.get( attributeValue ); - } - ) - ); - - return fetchedItems.map( ( item ) => { - const element = document.createElement( 'style' ); - element.textContent = item; - return element; - } ); -}; - -const fetchScript = async ( document ) => { - const fetchedItems = await Promise.all( - [].map.call( document.querySelectorAll( 'script[src]' ), ( el ) => { - const attributeValue = el.getAttribute( 'src' ); - if ( ! scripts.has( attributeValue ) ) - scripts.set( attributeValue, { - el, - text: fetch( attributeValue ).then( ( r ) => r.text() ), - } ); - return scripts.get( attributeValue ); - } ) - ); - - return fetchedItems.map( ( item ) => { - const element = document.createElement( 'script' ); - element.innerText = item.el.innerText; - for ( const attr of item.el.attributes ) { - element.setAttribute( attr.name, attr.value ); - } - - return element; - } ); -}; - -// Fetch styles of a new page. +// Fetch head assets of a new page. const fetchAssets = async ( document ) => { - const stylesFromSheets = await fetchStyle( document ); - const scriptTags = await fetchScript( document ); + 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 = document.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 ) ) { + const response = await fetch( attributeValue ); + const text = await response.text(); + headElements.set( attributeValue, { + tag, + text, + } ); + } + + const headElement = headElements.get( attributeValue ); + const element = document.createElement( tagName ); + element.innerText = headElement.text; + for ( const attr of headElement.tag.attributes ) { + element.setAttribute( attr.name, attr.value ); + } + headTags.push( element ); + } ) + ); + } return [ - ...scriptTags, document.querySelector( 'title' ), ...document.querySelectorAll( 'style' ), - ...stylesFromSheets, + ...headTags, ]; }; @@ -261,15 +231,12 @@ if ( canDoClientSideNavigation() ) { document.body ); // Cache the scripts. Has to be called before fetching the assets. - [].map.call( - document.querySelectorAll( 'script[type=module]' ), - ( script ) => { - scripts.set( script.getAttribute( 'src' ), { - el: script, - text: script.textContent, - } ); - } - ); + [].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => { + headElements.set( script.getAttribute( 'src' ), { + tag: script, + text: script.textContent, + } ); + } ); const head = await fetchAssets( document ); pages.set( cleanUrl( window.location ), From afda3e6725ba9a804ab81521db6959174de72204 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 8 Mar 2024 11:37:36 +0100 Subject: [PATCH 16/61] Adapt query loop block --- lib/experimental/full-client-side-navigation.php | 16 +++++++++++++--- packages/block-library/src/query/index.php | 8 ++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/experimental/full-client-side-navigation.php b/lib/experimental/full-client-side-navigation.php index 24f3f04a61cdce..651c97b26762a7 100644 --- a/lib/experimental/full-client-side-navigation.php +++ b/lib/experimental/full-client-side-navigation.php @@ -7,16 +7,26 @@ wp_interactivity_config( 'core/router', array( 'fullClientSideNavigation' => true ) ); // Add directives to all links. -function gutenberg_add_client_side_navigation_directives( $content ) { +// This should probably be done per site, not by default when this option is enabled. +function gutenberg_add_client_side_navigation_directives( $content, $block ) { + // Don't add directives to query blocks and pagination blocks. + if ( + 'core/query' === $block['blockName'] || + 'core/query-pagination-next' === $block['blockName'] || + 'core/query-pagination-previous' === $block['blockName'] || + 'core/query-pagination-numbers' === $block['blockName'] + ) { + return $content; + } $p = new WP_HTML_Tag_Processor( $content ); while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) { $p->set_attribute( 'data-wp-on--click', 'core/router::actions.navigate' ); - $p->set_attribute( 'data-wp-on--mouseover', 'core/router::actions.prefetch' ); + $p->set_attribute( 'data-wp-on--mouseenter', 'core/router::actions.prefetch' ); } return (string) $p; } -add_filter( 'render_block', 'gutenberg_add_client_side_navigation_directives', 10, 1 ); +add_filter( 'render_block', 'gutenberg_add_client_side_navigation_directives', 10, 2 ); // Add `data-wp-interactive` to the top level tag. function gutenberg_interactivity_add_directives_csn( array $parsed_block ): array { diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index b6c34eb71d0703..9462ec2217ad0b 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -17,13 +17,17 @@ * @return string Returns the modified output of the query block. */ function render_block_core_query( $attributes, $content, $block ) { - $is_interactive = isset( $attributes['enhancedPagination'] ) + $is_interactive = isset( $attributes['enhancedPagination'] ) && true === $attributes['enhancedPagination'] && isset( $attributes['queryId'] ); + $is_full_site_csn_enabled = false; + if ( ! empty( wp_interactivity_config( 'core/router' ) ) ) { + $is_full_site_csn_enabled = wp_interactivity_config( 'core/router' )['fullClientSideNavigation']; + } // Enqueue the script module and add the necessary directives if the block is // interactive. - if ( $is_interactive ) { + if ( $is_interactive || $is_full_site_csn_enabled ) { $suffix = wp_scripts_get_suffix(); if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { $module_url = gutenberg_url( '/build/interactivity/query.min.js' ); From 195e7e7acad6e2974c55e1a1c37bd76e9ed578f3 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 8 Mar 2024 11:44:28 +0100 Subject: [PATCH 17/61] Fix full CSN when queryID is not defined --- packages/block-library/src/query/index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index 9462ec2217ad0b..eb6010e0487088 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -22,7 +22,7 @@ function render_block_core_query( $attributes, $content, $block ) { && isset( $attributes['queryId'] ); $is_full_site_csn_enabled = false; if ( ! empty( wp_interactivity_config( 'core/router' ) ) ) { - $is_full_site_csn_enabled = wp_interactivity_config( 'core/router' )['fullClientSideNavigation']; + $is_full_site_csn_enabled = wp_interactivity_config( 'core/router' )['fullClientSideNavigation'] && isset( $attributes['queryId'] ); } // Enqueue the script module and add the necessary directives if the block is From c8348ab9327ae934c123262f27583145ef6aa049 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 8 Mar 2024 11:45:00 +0100 Subject: [PATCH 18/61] Remove preload logic --- packages/interactivity-router/src/full-csn.js | 48 ++----------------- 1 file changed, 4 insertions(+), 44 deletions(-) diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index eaa7f208cdab65..ab9846dc14bcb0 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -30,45 +30,10 @@ const cleanUrl = ( url ) => { const canDoClientSideNavigation = () => getConfig( 'core/router' ).fullClientSideNavigation; -/** - * 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. - */ - +// Helper to get the tag id store in the cache. const getTagId = ( tag ) => tag.id || tag.outerHTML; -const canBePreloaded = ( e ) => e.src || ( e.href && e.rel !== 'preload' ); - -const loadAsset = ( a ) => { - const loader = document.createElement( 'link' ); - loader.rel = 'preload'; - if ( a.nodeName === 'SCRIPT' ) { - loader.as = 'script'; - loader.href = a.getAttribute( 'src' ); - } else if ( a.nodeName === 'LINK' ) { - loader.as = 'style'; - loader.href = a.getAttribute( 'href' ); - } - - const p = new Promise( ( resolve, reject ) => { - loader.onload = () => resolve( loader ); - loader.onerror = () => reject( loader ); - } ); - - document.head.appendChild( loader ); - return p; -}; - +// Function to update only the necessary tags in the head. const updateHead = async ( newHead ) => { // Map incoming head tags by their content. const newHeadMap = new Map(); @@ -92,14 +57,8 @@ const updateHead = async ( newHead ) => { // Prepare new assets. const toAppend = [ ...newHeadMap.values() ]; - // Wait for all new assets to be loaded. - const loaders = await Promise.all( - toAppend.filter( canBePreloaded ).map( loadAsset ) - ); - // Apply the changes. toRemove.forEach( ( n ) => n.remove() ); - loaders.forEach( ( l ) => l && l.remove() ); document.head.append( ...toAppend ); }; @@ -161,6 +120,7 @@ const fetchPage = async ( url ) => { return { head, body: toVdom( dom.body ) }; }; +// Check if the link is valid for client-side navigation. const isValidLink = ( ref ) => ref && ref instanceof window.HTMLAnchorElement && @@ -168,6 +128,7 @@ const isValidLink = ( ref ) => ( ! ref.target || ref.target === '_self' ) && ref.origin === window.location.origin; +// Check if the event is valid for client-side navigation. const isValidEvent = ( event ) => event.button === 0 && // Left clicks only. ! event.metaKey && // Open in new tab (Mac). @@ -224,7 +185,6 @@ window.addEventListener( 'popstate', async () => { } ); // Initialize the router with the initial DOM. - if ( canDoClientSideNavigation() ) { rootFragment = createRootFragment( document.documentElement, From 69a70d644d74cd797ec314d1cdfb1dcb90ebff0f Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 8 Mar 2024 12:11:32 +0100 Subject: [PATCH 19/61] Change full csn conditional in query --- packages/block-library/src/query/index.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index eb6010e0487088..ef3453d12def24 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -17,12 +17,13 @@ * @return string Returns the modified output of the query block. */ function render_block_core_query( $attributes, $content, $block ) { - $is_interactive = isset( $attributes['enhancedPagination'] ) + $is_interactive = isset( $attributes['enhancedPagination'] ) && true === $attributes['enhancedPagination'] && isset( $attributes['queryId'] ); - $is_full_site_csn_enabled = false; - if ( ! empty( wp_interactivity_config( 'core/router' ) ) ) { - $is_full_site_csn_enabled = wp_interactivity_config( 'core/router' )['fullClientSideNavigation'] && isset( $attributes['queryId'] ); + $is_full_site_csn_enabled = false; + $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); + if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) && isset( $attributes['queryId'] ) ) { + $is_full_site_csn_enabled = true; } // Enqueue the script module and add the necessary directives if the block is From 9b2c853081c830212c11934d7407060cb577f164 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 8 Mar 2024 13:38:14 +0100 Subject: [PATCH 20/61] Use only one app in the body --- .../full-client-side-navigation.php | 47 ++----------------- 1 file changed, 3 insertions(+), 44 deletions(-) diff --git a/lib/experimental/full-client-side-navigation.php b/lib/experimental/full-client-side-navigation.php index 651c97b26762a7..1e822745a86418 100644 --- a/lib/experimental/full-client-side-navigation.php +++ b/lib/experimental/full-client-side-navigation.php @@ -23,50 +23,9 @@ function gutenberg_add_client_side_navigation_directives( $content, $block ) { $p->set_attribute( 'data-wp-on--click', 'core/router::actions.navigate' ); $p->set_attribute( 'data-wp-on--mouseenter', 'core/router::actions.prefetch' ); } - return (string) $p; + // Hack to add the necessary directives to the body tag. + // TODO: Find a proper way to add directives to the body tag. + return (string) $p . ''; } add_filter( 'render_block', 'gutenberg_add_client_side_navigation_directives', 10, 2 ); - -// Add `data-wp-interactive` to the top level tag. -function gutenberg_interactivity_add_directives_csn( array $parsed_block ): array { - static $root_block = null; - - /* - * Checks whether a root block is already annotated for - * processing, and if it is, it ignores the subsequent ones. - */ - if ( null === $root_block ) { - $block_name = $parsed_block['blockName']; - if ( isset( $block_name ) ) { - // Annotates the root block for processing. - $root_block = array( $block_name, $parsed_block ); - - /* - * Adds a filter to process the root interactive block once it has - * finished rendering. - */ - $add_directive_to_root_block = static function ( string $content, array $parsed_block ) use ( &$root_block, &$add_directive_to_root_block ): string { - // Checks whether the current block is the root block. - list($root_block_name, $root_parsed_block) = $root_block; - if ( $root_block_name === $parsed_block['blockName'] && $parsed_block === $root_parsed_block ) { - // The root block has finished rendering, process it. - $p = new WP_HTML_Tag_Processor( $content ); - if ( $p->next_tag() ) { - $p->set_attribute( 'data-wp-interactive', 'core/experimental' ); - } - $content = (string) $p; - // Removes the filter and reset the root block. - remove_filter( 'render_block_' . $parsed_block['blockName'], $add_directive_to_root_block ); - $root_block = null; - } - return $content; - }; - - add_filter( 'render_block_' . $block_name, $add_directive_to_root_block, 20, 2 ); - } - } - - return $parsed_block; -} -add_filter( 'render_block_data', 'gutenberg_interactivity_add_directives_csn' ); From 8f16b304b656734a11176797c9eed30a5031108b Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 8 Mar 2024 13:38:43 +0100 Subject: [PATCH 21/61] Use getRegionRootFragment and initialVdom --- packages/interactivity-router/src/full-csn.js | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index ab9846dc14bcb0..5100d1010083dd 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -8,13 +8,10 @@ import { getElement, } from '@wordpress/interactivity'; -const { createRootFragment, render, toVdom } = privateApis( +const { getRegionRootFragment, render, initialVdom, toVdom } = privateApis( 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.' ); -// The root to render the vdom (document.body). -let rootFragment; - // The cache of visited and prefetched pages, stylesheets and scripts. const pages = new Map(); const headElements = new Map(); @@ -151,7 +148,8 @@ const { actions } = store( 'core/router', { if ( page ) { yield updateHead( page.head ); - yield nextTick( () => render( page.body, rootFragment ) ); + const fragment = getRegionRootFragment( document.body ); + yield nextTick( () => render( page.body, fragment ) ); window.history.pushState( {}, '', href ); } else { window.location.assign( href ); @@ -161,7 +159,7 @@ const { actions } = store( 'core/router', { prefetch( event, url ) { if ( ! canDoClientSideNavigation() ) return; const { ref } = getElement(); - const href = url ? url : ref.href; + const href = url ? url : ref?.href; const newUrl = cleanUrl( href ); if ( ! pages.has( newUrl ) ) { @@ -177,8 +175,9 @@ 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 ); + await updateHead( ...page.head ); + const fragment = getRegionRootFragment( document.body ); + render( page.body, fragment ); } else { window.location.reload(); } @@ -186,10 +185,6 @@ window.addEventListener( 'popstate', async () => { // Initialize the router with the initial DOM. if ( canDoClientSideNavigation() ) { - rootFragment = createRootFragment( - document.documentElement, - document.body - ); // Cache the scripts. Has to be called before fetching the assets. [].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => { headElements.set( script.getAttribute( 'src' ), { @@ -200,6 +195,6 @@ if ( canDoClientSideNavigation() ) { const head = await fetchAssets( document ); pages.set( cleanUrl( window.location ), - Promise.resolve( { head, body: toVdom( document.body ) } ) + Promise.resolve( { head, body: initialVdom.get( document.body ) } ) ); } From 7f458b12c9a5dd65311fa6d98003db8bac983f55 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 8 Mar 2024 17:16:16 +0100 Subject: [PATCH 22/61] Adapt all query loop blocks --- lib/experimental/editor-settings.php | 3 +++ packages/block-library/src/post-template/index.php | 10 +++++++--- .../src/query-pagination-next/index.php | 12 ++++++++---- .../src/query-pagination-numbers/index.php | 12 ++++++++---- .../src/query-pagination-previous/index.php | 10 +++++++--- .../src/query/edit/enhanced-pagination-modal.js | 6 +++++- .../enhanced-pagination-control.js | 10 +++++++--- packages/block-library/src/query/index.php | 5 ++--- 8 files changed, 47 insertions(+), 21 deletions(-) diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index f5d40ae8a2110c..9e70df48924d23 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-client-side-navigation' ) ) { + wp_add_inline_script( 'wp-block-library', 'window.__experimentalFullClientSideNavigation = true', 'before' ); + } } add_action( 'admin_init', 'gutenberg_enable_experiments' ); diff --git a/packages/block-library/src/post-template/index.php b/packages/block-library/src/post-template/index.php index df287acae7b58b..511d5570d1bde8 100644 --- a/packages/block-library/src/post-template/index.php +++ b/packages/block-library/src/post-template/index.php @@ -45,9 +45,13 @@ function block_core_post_template_uses_featured_image( $inner_blocks ) { * @return string Returns the output of the query, structured using the layout defined by the block's inner blocks. */ function render_block_core_post_template( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); + if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + $enhanced_pagination = true; + } + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; // Use global query if needed. $use_global_query = ( isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] ); diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index ca134f62192f9e..aee58ffc5294f8 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -15,10 +15,14 @@ * @return string Returns the next posts link for the query pagination. */ function render_block_core_query_pagination_next( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; - $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); + if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + $enhanced_pagination = true; + } + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; $wrapper_attributes = get_block_wrapper_attributes(); $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index e6f8b461110407..8a38ef61a285d8 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -15,10 +15,14 @@ * @return string Returns the pagination numbers for the Query. */ function render_block_core_query_pagination_numbers( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; - $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); + if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + $enhanced_pagination = true; + } + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; $wrapper_attributes = get_block_wrapper_attributes(); $content = ''; diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index b49130a44d8ddf..8bbc39cce19cd4 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -15,9 +15,13 @@ * @return string Returns the previous posts link for the query. */ function render_block_core_query_pagination_previous( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); + if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + $enhanced_pagination = true; + } + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; $wrapper_attributes = get_block_wrapper_attributes(); $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; 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 6009881c7bd862..0da0f5ae8e4310 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.__experimentalFullClientSideNavigation + ) { 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 de889c0715c07a..ca60a3e5367e6e 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,13 @@ export default function EnhancedPaginationControl( { clientId, } ) { const { hasUnsupportedBlocks } = useUnsupportedBlocks( clientId ); + const fullClientSideNavigation = + window.__experimentalFullClientSideNavigation; let help = __( 'Browsing between pages requires a full page reload.' ); - if ( enhancedPagination ) { + if ( fullClientSideNavigation ) { + help = __( 'Full client-side navigation enabled.' ); + } else if ( enhancedPagination ) { help = __( "Browsing between pages won't require a full page reload, unless non-compatible blocks are detected." ); @@ -32,8 +36,8 @@ 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 ef3453d12def24..aa156ea01928f1 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -20,15 +20,14 @@ function render_block_core_query( $attributes, $content, $block ) { $is_interactive = isset( $attributes['enhancedPagination'] ) && true === $attributes['enhancedPagination'] && isset( $attributes['queryId'] ); - $is_full_site_csn_enabled = false; $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) && isset( $attributes['queryId'] ) ) { - $is_full_site_csn_enabled = true; + $is_interactive = true; } // Enqueue the script module and add the necessary directives if the block is // interactive. - if ( $is_interactive || $is_full_site_csn_enabled ) { + if ( $is_interactive ) { $suffix = wp_scripts_get_suffix(); if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { $module_url = gutenberg_url( '/build/interactivity/query.min.js' ); From 1d96c642b8f10ea416b9485a4cf3439f3d4dfca9 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 8 Mar 2024 17:16:59 +0100 Subject: [PATCH 23/61] Add key to query loop block --- packages/block-library/src/query/index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index aa156ea01928f1..12b3d9f8585a4d 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -55,8 +55,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(); } } From 63c81a60ea58ab0c21224282625d7cf03e27cfd1 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 8 Mar 2024 17:18:53 +0100 Subject: [PATCH 24/61] Add `yield` to query block actions --- packages/block-library/src/query/view.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 191969c18b9658..517c7b280b919a 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -47,7 +47,10 @@ store( const { ref } = getElement(); if ( isValidLink( ref ) ) { yield import( '@wordpress/interactivity-router' ); - store( 'core/router' ).actions.prefetch( event, ref.href ); + yield store( 'core/router' ).actions.prefetch( + event, + ref.href + ); } }, }, @@ -57,7 +60,10 @@ store( const { ref } = getElement(); if ( url && isValidLink( ref ) ) { yield import( '@wordpress/interactivity-router' ); - store( 'core/router' ).actions.prefetch( event, ref.href ); + yield store( 'core/router' ).actions.prefetch( + event, + ref.href + ); } }, }, From 652a2a2de432dd132c364296565d99b5ca2df4a6 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 22 Mar 2024 10:56:10 +0100 Subject: [PATCH 25/61] Revert conditional scripts depending on the experiment --- lib/interactivity-api.php | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/lib/interactivity-api.php b/lib/interactivity-api.php index 804590a8795c06..da349e1c033760 100644 --- a/lib/interactivity-api.php +++ b/lib/interactivity-api.php @@ -21,22 +21,12 @@ function gutenberg_reregister_interactivity_script_modules() { $default_version ); - if ( gutenberg_is_experiment_enabled( 'gutenberg-full-client-side-navigation' ) ) { - wp_register_script_module( - '@wordpress/interactivity-router', - gutenberg_url( '/build/interactivity/full-csn.min.js' ), - array( '@wordpress/interactivity' ), - $default_version - ); - wp_enqueue_script_module( '@wordpress/interactivity-router' ); - } else { - wp_register_script_module( - '@wordpress/interactivity-router', - gutenberg_url( '/build/interactivity/router.min.js' ), - array( '@wordpress/interactivity' ), - $default_version - ); - } + wp_register_script_module( + '@wordpress/interactivity-router', + gutenberg_url( '/build/interactivity/router.min.js' ), + array( '@wordpress/interactivity' ), + $default_version + ); } add_action( 'init', 'gutenberg_reregister_interactivity_script_modules' ); From 442248c809b4eebffae92fbc1120a4a164f069ce Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 22 Mar 2024 10:56:27 +0100 Subject: [PATCH 26/61] Register `interactivity-router-full-client-side-navigation` in the experiment --- lib/experimental/full-client-side-navigation.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/experimental/full-client-side-navigation.php b/lib/experimental/full-client-side-navigation.php index 1e822745a86418..1a8cf61389d7ef 100644 --- a/lib/experimental/full-client-side-navigation.php +++ b/lib/experimental/full-client-side-navigation.php @@ -6,6 +6,15 @@ // Add the full client-side navigation config option. wp_interactivity_config( 'core/router', array( 'fullClientSideNavigation' => true ) ); +// Register and enqueue the full client-side navigation script. +wp_register_script_module( + '@wordpress/interactivity-router-full-client-side-navigation', + gutenberg_url( '/build/interactivity/full-csn.min.js' ), + array( '@wordpress/interactivity' ), + false +); +wp_enqueue_script_module( '@wordpress/interactivity-router-full-client-side-navigation' ); + // Add directives to all links. // This should probably be done per site, not by default when this option is enabled. function gutenberg_add_client_side_navigation_directives( $content, $block ) { From 0f3bc44fbe754f5cadd0957c93505078bfe7f22a Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 22 Mar 2024 11:05:27 +0100 Subject: [PATCH 27/61] Load router conditionally in query loop --- packages/block-library/src/query/view.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 517c7b280b919a..cab1d885e11f65 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -1,7 +1,12 @@ /** * WordPress dependencies */ -import { store, getContext, getElement } from '@wordpress/interactivity'; +import { + store, + getConfig, + getContext, + getElement, +} from '@wordpress/interactivity'; const isValidLink = ( ref ) => ref && @@ -18,6 +23,12 @@ const isValidEvent = ( event ) => ! event.shiftKey && ! event.defaultPrevented; +// Helper to load the router depending on if full client-side navigation is enabled or not. +const loadInteractivityRouter = async () => { + if ( getConfig( 'core/router' ).fullClientSideNavigation ) return; + await import( '@wordpress/interactivity-router' ); +}; + store( 'core/query', { @@ -32,10 +43,8 @@ store( if ( isValidLink( ref ) && isValidEvent( event ) ) { event.preventDefault(); - const { actions } = yield import( - '@wordpress/interactivity-router' - ); - yield actions.navigate( ref.href ); + yield loadInteractivityRouter(); + yield store( 'core/router' ).actions.navigate( ref.href ); ctx.url = ref.href; // Focus the first anchor of the Query block. @@ -46,7 +55,7 @@ store( *prefetch( event ) { const { ref } = getElement(); if ( isValidLink( ref ) ) { - yield import( '@wordpress/interactivity-router' ); + yield loadInteractivityRouter(); yield store( 'core/router' ).actions.prefetch( event, ref.href @@ -59,7 +68,7 @@ store( const { url } = getContext(); const { ref } = getElement(); if ( url && isValidLink( ref ) ) { - yield import( '@wordpress/interactivity-router' ); + yield loadInteractivityRouter(); yield store( 'core/router' ).actions.prefetch( event, ref.href From 62f694892258acc73b4f5231280fa99f818b8718 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 22 Mar 2024 12:20:53 +0100 Subject: [PATCH 28/61] Scroll to anchor --- packages/interactivity-router/src/full-csn.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index 5100d1010083dd..312706d9276671 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -154,6 +154,13 @@ const { actions } = store( 'core/router', { } else { window.location.assign( href ); } + + // Scroll to the anchor if exits in the link. + if ( !! event.target?.hash ) { + document + .querySelector( event.target.hash ) + ?.scrollIntoView(); + } } }, prefetch( event, url ) { From a26bfa70e582d5b06d186394301a9748db8a90eb Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 22 Mar 2024 12:30:48 +0100 Subject: [PATCH 29/61] Remove unnecessary empty conditional --- packages/block-library/src/post-template/index.php | 2 +- packages/block-library/src/query-pagination-next/index.php | 2 +- packages/block-library/src/query-pagination-numbers/index.php | 2 +- packages/block-library/src/query-pagination-previous/index.php | 2 +- packages/block-library/src/query/index.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/block-library/src/post-template/index.php b/packages/block-library/src/post-template/index.php index 511d5570d1bde8..00b26db7f5ee9d 100644 --- a/packages/block-library/src/post-template/index.php +++ b/packages/block-library/src/post-template/index.php @@ -48,7 +48,7 @@ function render_block_core_post_template( $attributes, $content, $block ) { $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { $enhanced_pagination = true; } $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index aee58ffc5294f8..0d6084b6971731 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -18,7 +18,7 @@ function render_block_core_query_pagination_next( $attributes, $content, $block $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { $enhanced_pagination = true; } $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index 8a38ef61a285d8..3f7d1bac77aff7 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -18,7 +18,7 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { $enhanced_pagination = true; } $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index 8bbc39cce19cd4..bd614888b71f1f 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -18,7 +18,7 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { $enhanced_pagination = true; } $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index 12b3d9f8585a4d..7896cbf2c195c0 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -21,7 +21,7 @@ function render_block_core_query( $attributes, $content, $block ) { && true === $attributes['enhancedPagination'] && isset( $attributes['queryId'] ); $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config ) && ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) && isset( $attributes['queryId'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) && isset( $attributes['queryId'] ) ) { $is_interactive = true; } From a5a7279cdb5656f946f2c4188f5f181eb2a71a29 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 22 Mar 2024 12:33:29 +0100 Subject: [PATCH 30/61] Fix back and forward buttons --- packages/interactivity-router/src/full-csn.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index 312706d9276671..0106c2c9a68282 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -182,7 +182,7 @@ window.addEventListener( 'popstate', async () => { const url = cleanUrl( window.location ); // Remove hash. const page = pages.has( url ) && ( await pages.get( url ) ); if ( page ) { - await updateHead( ...page.head ); + await updateHead( page.head ); const fragment = getRegionRootFragment( document.body ); render( page.body, fragment ); } else { From f984294e1fda643d8c312aee4bc5b232b927e6e7 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 22 Mar 2024 13:11:11 +0100 Subject: [PATCH 31/61] Fix query loop --- packages/block-library/src/query/view.js | 5 ++++- packages/interactivity-router/src/index.js | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index cab1d885e11f65..8d1ad66dd76370 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -44,7 +44,10 @@ store( event.preventDefault(); yield loadInteractivityRouter(); - yield store( 'core/router' ).actions.navigate( ref.href ); + yield store( 'core/router' ).actions.navigate( + event, + ref.href + ); ctx.url = ref.href; // Focus the first anchor of the Query block. diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 03d399338167ce..90ca952e753b70 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -128,6 +128,7 @@ export const { state, actions } = store( 'core/router', { * needed, and updates any interactive regions whose contents have * changed. It also creates a new entry in the browser session history. * + * @param {Object} event The event which takes place on EventTarget (unused). * @param {string} href The page href. * @param {Object} [options] Options object. * @param {boolean} [options.force] If true, it forces re-fetching the URL. @@ -139,7 +140,7 @@ export const { state, actions } = store( 'core/router', { * * @return {Promise} Promise that resolves once the navigation is completed or aborted. */ - *navigate( href, options = {} ) { + *navigate( event, href, options = {} ) { const { clientNavigationDisabled } = getConfig(); if ( clientNavigationDisabled ) { yield forcePageReload( href ); @@ -154,7 +155,7 @@ export const { state, actions } = store( 'core/router', { } = options; navigatingTo = href; - actions.prefetch( pagePath, options ); + actions.prefetch( null, pagePath, options ); // Create a promise that resolves when the specified timeout ends. // The timeout value is 10 seconds by default. @@ -229,13 +230,14 @@ export const { state, actions } = store( 'core/router', { * The function normalizes the URL and stores internally the fetch * promise, to avoid triggering a second fetch for an ongoing request. * + * @param {Object} event The event which takes place on EventTarget (unused). * @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. */ - prefetch( url, options = {} ) { + prefetch( event, url, options = {} ) { const { clientNavigationDisabled } = getConfig(); if ( clientNavigationDisabled ) return; From 0f7a8db32e87fc72f0072feffe8896c41f8866bd Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Fri, 22 Mar 2024 13:16:16 +0100 Subject: [PATCH 32/61] Remove unnecessary conditional --- packages/interactivity-router/src/full-csn.js | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-csn.js index 0106c2c9a68282..d53a07badbe583 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-csn.js @@ -191,17 +191,15 @@ window.addEventListener( 'popstate', async () => { } ); // Initialize the router with the initial DOM. -if ( canDoClientSideNavigation() ) { - // 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, - } ); +// 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, } ); - const head = await fetchAssets( document ); - pages.set( - cleanUrl( window.location ), - Promise.resolve( { head, body: initialVdom.get( document.body ) } ) - ); -} +} ); +const head = await fetchAssets( document ); +pages.set( + cleanUrl( window.location ), + Promise.resolve( { head, body: initialVdom.get( document.body ) } ) +); From cd7a112cf072e2ce3d49c2ab912cbdc19eac77eb Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Mon, 25 Mar 2024 11:06:23 +0100 Subject: [PATCH 33/61] Use full page client-side navigation naming --- lib/experimental/editor-settings.php | 4 ++-- ...on.php => full-page-client-side-navigation.php} | 10 +++++----- lib/experiments-page.php | 8 ++++---- lib/load.php | 4 ++-- packages/block-library/src/post-template/index.php | 2 +- .../src/query-pagination-next/index.php | 2 +- .../src/query-pagination-numbers/index.php | 2 +- .../src/query-pagination-previous/index.php | 2 +- .../src/query/edit/enhanced-pagination-modal.js | 2 +- .../enhanced-pagination-control.js | 14 +++++++++----- packages/block-library/src/query/index.php | 2 +- packages/block-library/src/query/view.js | 2 +- .../src/{full-csn.js => full-page.js} | 2 +- tools/webpack/interactivity.js | 2 +- 14 files changed, 31 insertions(+), 27 deletions(-) rename lib/experimental/{full-client-side-navigation.php => full-page-client-side-navigation.php} (75%) rename packages/interactivity-router/src/{full-csn.js => full-page.js} (99%) diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 9e70df48924d23..cecdf5545c6759 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -28,8 +28,8 @@ 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-client-side-navigation' ) ) { - wp_add_inline_script( 'wp-block-library', 'window.__experimentalFullClientSideNavigation = true', 'before' ); + if ( gutenberg_is_experiment_enabled( 'gutenberg-full-page-client-side-navigation' ) ) { + wp_add_inline_script( 'wp-block-library', 'window.__experimentalFullPageClientSideNavigation = true', 'before' ); } } diff --git a/lib/experimental/full-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php similarity index 75% rename from lib/experimental/full-client-side-navigation.php rename to lib/experimental/full-page-client-side-navigation.php index 1a8cf61389d7ef..fcbf0095f21ea3 100644 --- a/lib/experimental/full-client-side-navigation.php +++ b/lib/experimental/full-page-client-side-navigation.php @@ -1,19 +1,19 @@ true ) ); +wp_interactivity_config( 'core/router', array( 'fullPageClientSideNavigation' => true ) ); // Register and enqueue the full client-side navigation script. wp_register_script_module( - '@wordpress/interactivity-router-full-client-side-navigation', - gutenberg_url( '/build/interactivity/full-csn.min.js' ), + '@wordpress/interactivity-full-page-router', + gutenberg_url( '/build/interactivity/full-page-router.min.js' ), array( '@wordpress/interactivity' ), false ); -wp_enqueue_script_module( '@wordpress/interactivity-router-full-client-side-navigation' ); +wp_enqueue_script_module( '@wordpress/interactivity-full-page-router' ); // Add directives to all links. // This should probably be done per site, not by default when this option is enabled. diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 71f65622831d93..9581859833759b 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -128,14 +128,14 @@ function gutenberg_initialize_experiments_settings() { ); add_settings_field( - 'gutenberg-full-client-side-navigation', - __( 'Enable full client-side navigation', 'gutenberg' ), + '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 client-side navigation using the Interactivity API', 'gutenberg' ), - 'id' => 'gutenberg-full-client-side-navigation', + 'label' => __( 'Enable full page client-side navigation using the Interactivity API', 'gutenberg' ), + 'id' => 'gutenberg-full-page-client-side-navigation', ) ); diff --git a/lib/load.php b/lib/load.php index a0a9745adcc13d..ab53e16a6c304a 100644 --- a/lib/load.php +++ b/lib/load.php @@ -192,8 +192,8 @@ 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-client-side-navigation' ) ) { - require __DIR__ . '/experimental/full-client-side-navigation.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. diff --git a/packages/block-library/src/post-template/index.php b/packages/block-library/src/post-template/index.php index 00b26db7f5ee9d..a3d47e7e40148e 100644 --- a/packages/block-library/src/post-template/index.php +++ b/packages/block-library/src/post-template/index.php @@ -48,7 +48,7 @@ function render_block_core_post_template( $attributes, $content, $block ) { $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) ) { $enhanced_pagination = true; } $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index 0d6084b6971731..c8fdb1ebc23a97 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -18,7 +18,7 @@ function render_block_core_query_pagination_next( $attributes, $content, $block $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) ) { $enhanced_pagination = true; } $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index 3f7d1bac77aff7..d18b6474b9e033 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -18,7 +18,7 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) ) { $enhanced_pagination = true; } $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index bd614888b71f1f..3ff09deb76c3f6 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -18,7 +18,7 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) ) { $enhanced_pagination = true; } $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; 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 0da0f5ae8e4310..4bc70ceb099619 100644 --- a/packages/block-library/src/query/edit/enhanced-pagination-modal.js +++ b/packages/block-library/src/query/edit/enhanced-pagination-modal.js @@ -30,7 +30,7 @@ export default function EnhancedPaginationModal( { if ( enhancedPagination && hasUnsupportedBlocks && - ! window.__experimentalFullClientSideNavigation + ! 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 ca60a3e5367e6e..778b8176050e20 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,11 +15,11 @@ export default function EnhancedPaginationControl( { clientId, } ) { const { hasUnsupportedBlocks } = useUnsupportedBlocks( clientId ); - const fullClientSideNavigation = - window.__experimentalFullClientSideNavigation; + const fullPageClientSideNavigation = + window.__experimentalFullPageClientSideNavigation; let help = __( 'Browsing between pages requires a full page reload.' ); - if ( fullClientSideNavigation ) { + if ( fullPageClientSideNavigation ) { help = __( 'Full client-side navigation enabled.' ); } else if ( enhancedPagination ) { help = __( @@ -36,8 +36,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 7896cbf2c195c0..4ec827b361ea38 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -21,7 +21,7 @@ function render_block_core_query( $attributes, $content, $block ) { && true === $attributes['enhancedPagination'] && isset( $attributes['queryId'] ); $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullClientSideNavigation'] ) && isset( $attributes['queryId'] ) ) { + if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) && isset( $attributes['queryId'] ) ) { $is_interactive = true; } diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 8d1ad66dd76370..51873932675543 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -25,7 +25,7 @@ const isValidEvent = ( event ) => // Helper to load the router depending on if full client-side navigation is enabled or not. const loadInteractivityRouter = async () => { - if ( getConfig( 'core/router' ).fullClientSideNavigation ) return; + if ( getConfig( 'core/router' ).fullPageClientSideNavigation ) return; await import( '@wordpress/interactivity-router' ); }; diff --git a/packages/interactivity-router/src/full-csn.js b/packages/interactivity-router/src/full-page.js similarity index 99% rename from packages/interactivity-router/src/full-csn.js rename to packages/interactivity-router/src/full-page.js index d53a07badbe583..0211abeec10ab8 100644 --- a/packages/interactivity-router/src/full-csn.js +++ b/packages/interactivity-router/src/full-page.js @@ -25,7 +25,7 @@ const cleanUrl = ( url ) => { // Helper to check if a page can do client-side navigation. const canDoClientSideNavigation = () => - getConfig( 'core/router' ).fullClientSideNavigation; + getConfig( 'core/router' ).fullPageClientSideNavigation; // Helper to get the tag id store in the cache. const getTagId = ( tag ) => tag.id || tag.outerHTML; diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index 2d8515cf7be244..c46d717837c7cb 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -19,7 +19,7 @@ module.exports = { entry: { index: './packages/interactivity', router: './packages/interactivity-router', - 'full-csn': './packages/interactivity-router/src/full-csn.js', + 'full-page-router': './packages/interactivity-router/src/full-page.js', navigation: './packages/block-library/src/navigation/view.js', query: './packages/block-library/src/query/view.js', image: './packages/block-library/src/image/view.js', From dd2b73eea12faea4e035f62e630e25dc625134be Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Mon, 25 Mar 2024 11:08:33 +0100 Subject: [PATCH 34/61] Change comments --- lib/experimental/full-page-client-side-navigation.php | 4 ++-- .../edit/inspector-controls/enhanced-pagination-control.js | 2 +- packages/block-library/src/query/view.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/experimental/full-page-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php index fcbf0095f21ea3..5a4a8dcee17a9c 100644 --- a/lib/experimental/full-page-client-side-navigation.php +++ b/lib/experimental/full-page-client-side-navigation.php @@ -3,10 +3,10 @@ * Registers full page client-side navigation option using the Interactivity API and adds the necessary directives. */ -// Add the full client-side navigation config option. +// Add the full page client-side navigation config option. wp_interactivity_config( 'core/router', array( 'fullPageClientSideNavigation' => true ) ); -// Register and enqueue the full client-side navigation script. +// Register and enqueue the full page client-side navigation script. wp_register_script_module( '@wordpress/interactivity-full-page-router', gutenberg_url( '/build/interactivity/full-page-router.min.js' ), 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 778b8176050e20..cae45e3836802c 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 @@ -20,7 +20,7 @@ export default function EnhancedPaginationControl( { let help = __( 'Browsing between pages requires a full page reload.' ); if ( fullPageClientSideNavigation ) { - help = __( 'Full client-side navigation enabled.' ); + help = __( 'Full page client-side navigation enabled.' ); } else if ( enhancedPagination ) { help = __( "Browsing between pages won't require a full page reload, unless non-compatible blocks are detected." diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 51873932675543..3ea469c1dd8fd6 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -23,7 +23,7 @@ const isValidEvent = ( event ) => ! event.shiftKey && ! event.defaultPrevented; -// Helper to load the router depending on if full client-side navigation is enabled or not. +// Helper to load the router depending on if full page client-side navigation is enabled or not. const loadInteractivityRouter = async () => { if ( getConfig( 'core/router' ).fullPageClientSideNavigation ) return; await import( '@wordpress/interactivity-router' ); From 7eef1ce354bc678d86799a0f76149fc8c9d8a586 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Mon, 25 Mar 2024 17:33:45 +0100 Subject: [PATCH 35/61] Use render_block_data to change query attribute --- .../full-page-client-side-navigation.php | 36 +++++++++++-------- .../block-library/src/post-template/index.php | 10 ++---- .../src/query-pagination-next/index.php | 12 +++---- .../src/query-pagination-numbers/index.php | 12 +++---- .../src/query-pagination-previous/index.php | 10 ++---- packages/block-library/src/query/index.php | 6 +--- 6 files changed, 36 insertions(+), 50 deletions(-) diff --git a/lib/experimental/full-page-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php index 5a4a8dcee17a9c..7d3a58f11c8e20 100644 --- a/lib/experimental/full-page-client-side-navigation.php +++ b/lib/experimental/full-page-client-side-navigation.php @@ -3,9 +3,6 @@ * Registers full page client-side navigation option using the Interactivity API and adds the necessary directives. */ -// Add the full page client-side navigation config option. -wp_interactivity_config( 'core/router', array( 'fullPageClientSideNavigation' => true ) ); - // Register and enqueue the full page client-side navigation script. wp_register_script_module( '@wordpress/interactivity-full-page-router', @@ -17,24 +14,33 @@ // Add directives to all links. // This should probably be done per site, not by default when this option is enabled. -function gutenberg_add_client_side_navigation_directives( $content, $block ) { - // Don't add directives to query blocks and pagination blocks. - if ( - 'core/query' === $block['blockName'] || - 'core/query-pagination-next' === $block['blockName'] || - 'core/query-pagination-previous' === $block['blockName'] || - 'core/query-pagination-numbers' === $block['blockName'] - ) { - return $content; +function _gutenberg_add_enhanced_pagination( $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', 10 ); + +// Add directives to all links. +// This should probably be done per site, not by default when this option is enabled. +function _gutenberg_add_client_side_navigation_directives( $content, $block ) { $p = new WP_HTML_Tag_Processor( $content ); while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) { - $p->set_attribute( 'data-wp-on--click', 'core/router::actions.navigate' ); - $p->set_attribute( 'data-wp-on--mouseenter', 'core/router::actions.prefetch' ); + if ( empty( $p->get_attribute( 'data-wp-on--click' ) ) ) { + $p->set_attribute( 'data-wp-on--click', 'core/router::actions.navigate' ); + } + if ( empty( $p->get_attribute( 'data-wp-on--mouseenter' ) ) ) { + $p->set_attribute( 'data-wp-on--mouseenter', 'core/router::actions.prefetch' ); + } } // Hack to add the necessary directives to the body tag. // TODO: Find a proper way to add directives to the body tag. return (string) $p . ''; } -add_filter( 'render_block', 'gutenberg_add_client_side_navigation_directives', 10, 2 ); +// TODO: Explore moving this to the server directive processing. +add_filter( 'render_block', '_gutenberg_add_client_side_navigation_directives', 10, 2 ); diff --git a/packages/block-library/src/post-template/index.php b/packages/block-library/src/post-template/index.php index a3d47e7e40148e..df287acae7b58b 100644 --- a/packages/block-library/src/post-template/index.php +++ b/packages/block-library/src/post-template/index.php @@ -45,13 +45,9 @@ function block_core_post_template_uses_featured_image( $inner_blocks ) { * @return string Returns the output of the query, structured using the layout defined by the block's inner blocks. */ function render_block_core_post_template( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; - $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) ) { - $enhanced_pagination = true; - } - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; // Use global query if needed. $use_global_query = ( isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] ); diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index c8fdb1ebc23a97..ca134f62192f9e 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -15,14 +15,10 @@ * @return string Returns the next posts link for the query pagination. */ function render_block_core_query_pagination_next( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; - $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) ) { - $enhanced_pagination = true; - } - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; - $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; $wrapper_attributes = get_block_wrapper_attributes(); $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index d18b6474b9e033..e6f8b461110407 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -15,14 +15,10 @@ * @return string Returns the pagination numbers for the Query. */ function render_block_core_query_pagination_numbers( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; - $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) ) { - $enhanced_pagination = true; - } - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; - $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; $wrapper_attributes = get_block_wrapper_attributes(); $content = ''; diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index 3ff09deb76c3f6..b49130a44d8ddf 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -15,13 +15,9 @@ * @return string Returns the previous posts link for the query. */ function render_block_core_query_pagination_previous( $attributes, $content, $block ) { - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; - $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) ) { - $enhanced_pagination = true; - } - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; $wrapper_attributes = get_block_wrapper_attributes(); $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index 4ec827b361ea38..88b3af438dbe53 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -17,13 +17,9 @@ * @return string Returns the modified output of the query block. */ function render_block_core_query( $attributes, $content, $block ) { - $is_interactive = isset( $attributes['enhancedPagination'] ) + $is_interactive = isset( $attributes['enhancedPagination'] ) && true === $attributes['enhancedPagination'] && isset( $attributes['queryId'] ); - $interactivity_api_router_config = wp_interactivity_config( 'core/router' ); - if ( ! empty( $interactivity_api_router_config['fullPageClientSideNavigation'] ) && isset( $attributes['queryId'] ) ) { - $is_interactive = true; - } // Enqueue the script module and add the necessary directives if the block is // interactive. From f0feceef73276c3a4e46f9e6ba4424b218f54722 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Mon, 25 Mar 2024 17:36:35 +0100 Subject: [PATCH 36/61] Refactor JavaScript logic --- packages/block-library/src/query/view.js | 37 +++++++-------- .../interactivity-router/src/full-page.js | 46 ++++++++++++------- packages/interactivity-router/src/index.js | 8 ++-- 3 files changed, 49 insertions(+), 42 deletions(-) diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 3ea469c1dd8fd6..9b1fbefc0525d4 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -1,12 +1,7 @@ /** * WordPress dependencies */ -import { - store, - getConfig, - getContext, - getElement, -} from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; const isValidLink = ( ref ) => ref && @@ -25,7 +20,16 @@ const isValidEvent = ( event ) => // Helper to load the router depending on if full page client-side navigation is enabled or not. const loadInteractivityRouter = async () => { - if ( getConfig( 'core/router' ).fullPageClientSideNavigation ) return; + if ( + store( + 'core/experimental', + {}, + { + lock: 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.', + } + )?.state?.fullPageClientSideNavigation + ) + return; await import( '@wordpress/interactivity-router' ); }; @@ -44,10 +48,7 @@ store( event.preventDefault(); yield loadInteractivityRouter(); - yield store( 'core/router' ).actions.navigate( - event, - ref.href - ); + yield store( 'core/router' ).actions.navigate( ref.href ); ctx.url = ref.href; // Focus the first anchor of the Query block. @@ -55,27 +56,21 @@ store( queryRef.querySelector( firstAnchor )?.focus(); } }, - *prefetch( event ) { + *prefetch() { const { ref } = getElement(); if ( isValidLink( ref ) ) { yield loadInteractivityRouter(); - yield store( 'core/router' ).actions.prefetch( - event, - ref.href - ); + yield store( 'core/router' ).actions.prefetch( ref.href ); } }, }, callbacks: { - *prefetch( event ) { + *prefetch() { const { url } = getContext(); const { ref } = getElement(); if ( url && isValidLink( ref ) ) { yield loadInteractivityRouter(); - yield store( 'core/router' ).actions.prefetch( - event, - ref.href - ); + yield store( 'core/router' ).actions.prefetch( ref.href ); } }, }, diff --git a/packages/interactivity-router/src/full-page.js b/packages/interactivity-router/src/full-page.js index 0211abeec10ab8..b218bb1e863138 100644 --- a/packages/interactivity-router/src/full-page.js +++ b/packages/interactivity-router/src/full-page.js @@ -1,12 +1,7 @@ /** * WordPress dependencies */ -import { - privateApis, - store, - getConfig, - getElement, -} from '@wordpress/interactivity'; +import { privateApis, store, getElement } from '@wordpress/interactivity'; const { getRegionRootFragment, render, initialVdom, toVdom } = privateApis( 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.' @@ -25,7 +20,13 @@ const cleanUrl = ( url ) => { // Helper to check if a page can do client-side navigation. const canDoClientSideNavigation = () => - getConfig( 'core/router' ).fullPageClientSideNavigation; + store( + 'core/experimental', + {}, + { + lock: 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.', + } + )?.state?.fullPageClientSideNavigation; // Helper to get the tag id store in the cache. const getTagId = ( tag ) => tag.id || tag.outerHTML; @@ -127,6 +128,7 @@ const isValidLink = ( ref ) => // 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). @@ -134,16 +136,30 @@ const isValidEvent = ( event ) => ! event.shiftKey && ! event.defaultPrevented; +// Private store to check if the experiment is enabled. +// In the future, we should probably use the config settings. +store( + 'core/experimental', + { + state: { + fullPageClientSideNavigation: true, + }, + }, + { lock: true } +); + const { actions } = store( 'core/router', { actions: { - *navigate( event, url ) { + *navigate( eventOrUrl ) { const { ref } = getElement(); + const url = typeof eventOrUrl === 'string' && eventOrUrl; + const event = eventOrUrl instanceof Event && eventOrUrl; if ( url || ( isValidLink( ref ) && isValidEvent( event ) ) ) { + if ( event ) event.preventDefault(); const href = url ? url : ref.href; - event.preventDefault(); const newUrl = cleanUrl( href ); - yield actions.prefetch( event, newUrl ); + yield actions.prefetch( newUrl ); const page = yield pages.get( newUrl ); if ( page ) { @@ -156,15 +172,13 @@ const { actions } = store( 'core/router', { } // Scroll to the anchor if exits in the link. - if ( !! event.target?.hash ) { - document - .querySelector( event.target.hash ) - ?.scrollIntoView(); - } + const { hash } = new URL( href, window.location ); + if ( hash ) document.querySelector( hash )?.scrollIntoView(); } }, - prefetch( event, url ) { + prefetch( eventOrUrl ) { if ( ! canDoClientSideNavigation() ) return; + const url = typeof eventOrUrl === 'string' && eventOrUrl; const { ref } = getElement(); const href = url ? url : ref?.href; diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 90ca952e753b70..03d399338167ce 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -128,7 +128,6 @@ export const { state, actions } = store( 'core/router', { * needed, and updates any interactive regions whose contents have * changed. It also creates a new entry in the browser session history. * - * @param {Object} event The event which takes place on EventTarget (unused). * @param {string} href The page href. * @param {Object} [options] Options object. * @param {boolean} [options.force] If true, it forces re-fetching the URL. @@ -140,7 +139,7 @@ export const { state, actions } = store( 'core/router', { * * @return {Promise} Promise that resolves once the navigation is completed or aborted. */ - *navigate( event, href, options = {} ) { + *navigate( href, options = {} ) { const { clientNavigationDisabled } = getConfig(); if ( clientNavigationDisabled ) { yield forcePageReload( href ); @@ -155,7 +154,7 @@ export const { state, actions } = store( 'core/router', { } = options; navigatingTo = href; - actions.prefetch( null, pagePath, options ); + actions.prefetch( pagePath, options ); // Create a promise that resolves when the specified timeout ends. // The timeout value is 10 seconds by default. @@ -230,14 +229,13 @@ export const { state, actions } = store( 'core/router', { * The function normalizes the URL and stores internally the fetch * promise, to avoid triggering a second fetch for an ongoing request. * - * @param {Object} event The event which takes place on EventTarget (unused). * @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. */ - prefetch( event, url, options = {} ) { + prefetch( url, options = {} ) { const { clientNavigationDisabled } = getConfig(); if ( clientNavigationDisabled ) return; From 70813588acebf2bd016a4365e6c61575dc14f00d Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 26 Mar 2024 08:47:28 +0100 Subject: [PATCH 37/61] Remove unused variable --- lib/experimental/full-page-client-side-navigation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/experimental/full-page-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php index 7d3a58f11c8e20..846d8359d18ca5 100644 --- a/lib/experimental/full-page-client-side-navigation.php +++ b/lib/experimental/full-page-client-side-navigation.php @@ -27,7 +27,7 @@ function _gutenberg_add_enhanced_pagination( $parsed_block ) { // Add directives to all links. // This should probably be done per site, not by default when this option is enabled. -function _gutenberg_add_client_side_navigation_directives( $content, $block ) { +function _gutenberg_add_client_side_navigation_directives( $content ) { $p = new WP_HTML_Tag_Processor( $content ); while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) { if ( empty( $p->get_attribute( 'data-wp-on--click' ) ) ) { From 1d00a3c9af8dab234d4ca8708e14962e9180b4a9 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 26 Mar 2024 18:38:17 +0100 Subject: [PATCH 38/61] Revert changes in query block view.js file --- packages/block-library/src/query/view.js | 33 +++++++++--------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 9b1fbefc0525d4..e23294a24e02e3 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -18,21 +18,6 @@ const isValidEvent = ( event ) => ! event.shiftKey && ! event.defaultPrevented; -// Helper to load the router depending on if full page client-side navigation is enabled or not. -const loadInteractivityRouter = async () => { - if ( - store( - 'core/experimental', - {}, - { - lock: 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.', - } - )?.state?.fullPageClientSideNavigation - ) - return; - await import( '@wordpress/interactivity-router' ); -}; - store( 'core/query', { @@ -47,8 +32,10 @@ store( if ( isValidLink( ref ) && isValidEvent( event ) ) { event.preventDefault(); - yield loadInteractivityRouter(); - yield store( 'core/router' ).actions.navigate( ref.href ); + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.navigate( ref.href ); ctx.url = ref.href; // Focus the first anchor of the Query block. @@ -59,8 +46,10 @@ store( *prefetch() { const { ref } = getElement(); if ( isValidLink( ref ) ) { - yield loadInteractivityRouter(); - yield store( 'core/router' ).actions.prefetch( ref.href ); + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.prefetch( ref.href ); } }, }, @@ -69,8 +58,10 @@ store( const { url } = getContext(); const { ref } = getElement(); if ( url && isValidLink( ref ) ) { - yield loadInteractivityRouter(); - yield store( 'core/router' ).actions.prefetch( ref.href ); + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.prefetch( ref.href ); } }, }, From a3181791bcfe634387d1573f2160c32445368eb7 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 26 Mar 2024 18:38:37 +0100 Subject: [PATCH 39/61] Remove unnecessary export from interactivity --- packages/interactivity/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 33b0fb6f585ced..3c91e919d91bdc 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -14,7 +14,6 @@ import { directivePrefix } from './constants'; import { toVdom } from './vdom'; import { directive, getNamespace } from './hooks'; import { parseInitialData, populateInitialData } from './store'; -import { createRootFragment } from './utils'; export { store, getConfig } from './store'; export { getContext, getElement } from './hooks'; @@ -38,7 +37,6 @@ export const privateApis = ( lock ): any => { return { directivePrefix, getRegionRootFragment, - createRootFragment, initialVdom, toVdom, directive, From 2138f0db56d2d0defd4f25471da3963a9ecf9e6d Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 26 Mar 2024 18:47:33 +0100 Subject: [PATCH 40/61] Move logic to the existing router --- .../full-page-client-side-navigation.php | 17 +- .../interactivity-router/src/full-page.js | 219 ------------------ packages/interactivity-router/src/head.js | 77 ++++++ packages/interactivity-router/src/index.js | 152 +++++++++--- tools/webpack/interactivity.js | 1 - 5 files changed, 200 insertions(+), 266 deletions(-) delete mode 100644 packages/interactivity-router/src/full-page.js create mode 100644 packages/interactivity-router/src/head.js diff --git a/lib/experimental/full-page-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php index 846d8359d18ca5..578deee7611603 100644 --- a/lib/experimental/full-page-client-side-navigation.php +++ b/lib/experimental/full-page-client-side-navigation.php @@ -3,14 +3,15 @@ * Registers full page client-side navigation option using the Interactivity API and adds the necessary directives. */ -// Register and enqueue the full page client-side navigation script. -wp_register_script_module( - '@wordpress/interactivity-full-page-router', - gutenberg_url( '/build/interactivity/full-page-router.min.js' ), - array( '@wordpress/interactivity' ), - false -); -wp_enqueue_script_module( '@wordpress/interactivity-full-page-router' ); +// Set the navigation mode to full page client-side navigation. +wp_interactivity_config( 'core/router', array( 'navigationMode' => 'fullPage' ) ); + +// Enqueue the interactivity router script. +function _gutenberg_enqueue_interactivity_router() { + wp_enqueue_script_module( '@wordpress/interactivity-router' ); +} + +add_action( 'init', '_gutenberg_enqueue_interactivity_router', 20 ); // Add directives to all links. // This should probably be done per site, not by default when this option is enabled. diff --git a/packages/interactivity-router/src/full-page.js b/packages/interactivity-router/src/full-page.js deleted file mode 100644 index b218bb1e863138..00000000000000 --- a/packages/interactivity-router/src/full-page.js +++ /dev/null @@ -1,219 +0,0 @@ -/** - * WordPress dependencies - */ -import { privateApis, store, getElement } from '@wordpress/interactivity'; - -const { getRegionRootFragment, render, initialVdom, toVdom } = privateApis( - '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, 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. -const cleanUrl = ( url ) => { - const u = new URL( url, window.location ); - return u.pathname + u.search; -}; - -// Helper to check if a page can do client-side navigation. -const canDoClientSideNavigation = () => - store( - 'core/experimental', - {}, - { - lock: 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.', - } - )?.state?.fullPageClientSideNavigation; - -// Helper to get the tag id store in the cache. -const getTagId = ( tag ) => tag.id || tag.outerHTML; - -// Function to update only the necessary tags in the head. -const updateHead = async ( newHead ) => { - // 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 ); -}; - -const nextTick = ( fn ) => - new Promise( ( resolve ) => setTimeout( () => resolve( fn() ) ) ); - -// Fetch head assets of a new page. -const fetchAssets = async ( document ) => { - 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 = document.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 ) ) { - const response = await fetch( attributeValue ); - const text = await response.text(); - headElements.set( attributeValue, { - tag, - text, - } ); - } - - const headElement = headElements.get( attributeValue ); - const element = document.createElement( tagName ); - element.innerText = headElement.text; - for ( const attr of headElement.tag.attributes ) { - element.setAttribute( attr.name, attr.value ); - } - headTags.push( element ); - } ) - ); - } - - return [ - document.querySelector( 'title' ), - ...document.querySelectorAll( 'style' ), - ...headTags, - ]; -}; - -// 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() ); - if ( ! canDoClientSideNavigation() ) return false; - const dom = new window.DOMParser().parseFromString( html, 'text/html' ); - const head = await fetchAssets( dom ); - return { head, body: toVdom( dom.body ) }; -}; - -// 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; - -// 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; - -// Private store to check if the experiment is enabled. -// In the future, we should probably use the config settings. -store( - 'core/experimental', - { - state: { - fullPageClientSideNavigation: true, - }, - }, - { lock: true } -); - -const { actions } = store( 'core/router', { - actions: { - *navigate( eventOrUrl ) { - const { ref } = getElement(); - const url = typeof eventOrUrl === 'string' && eventOrUrl; - const event = eventOrUrl instanceof Event && eventOrUrl; - - if ( url || ( isValidLink( ref ) && isValidEvent( event ) ) ) { - if ( event ) event.preventDefault(); - const href = url ? url : ref.href; - const newUrl = cleanUrl( href ); - yield actions.prefetch( newUrl ); - const page = yield pages.get( newUrl ); - - if ( page ) { - yield updateHead( page.head ); - const fragment = getRegionRootFragment( document.body ); - yield nextTick( () => render( page.body, fragment ) ); - window.history.pushState( {}, '', href ); - } else { - window.location.assign( href ); - } - - // Scroll to the anchor if exits in the link. - const { hash } = new URL( href, window.location ); - if ( hash ) document.querySelector( hash )?.scrollIntoView(); - } - }, - prefetch( eventOrUrl ) { - if ( ! canDoClientSideNavigation() ) return; - const url = typeof eventOrUrl === 'string' && eventOrUrl; - const { ref } = getElement(); - const href = url ? url : ref?.href; - - const newUrl = cleanUrl( href ); - if ( ! pages.has( newUrl ) ) { - pages.set( newUrl, fetchPage( newUrl ) ); - } - }, - }, -} ); - -// Listen to the back and forward buttons and restore the page if it's in the -// cache. -window.addEventListener( 'popstate', async () => { - const url = cleanUrl( window.location ); // Remove hash. - const page = pages.has( url ) && ( await pages.get( url ) ); - if ( page ) { - await updateHead( page.head ); - const fragment = getRegionRootFragment( document.body ); - render( page.body, fragment ); - } else { - window.location.reload(); - } -} ); - -// Initialize the router with the initial DOM. -// 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, - } ); -} ); -const head = await fetchAssets( document ); -pages.set( - cleanUrl( window.location ), - Promise.resolve( { head, body: initialVdom.get( document.body ) } ) -); diff --git a/packages/interactivity-router/src/head.js b/packages/interactivity-router/src/head.js new file mode 100644 index 00000000000000..dc0bef1498396c --- /dev/null +++ b/packages/interactivity-router/src/head.js @@ -0,0 +1,77 @@ +// Function to update only the necessary tags in the head. +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 ); +}; + +// Fetch head assets of a new page. +export const fetchHeadAssets = async ( document, 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 = document.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 ) ) { + const response = await fetch( attributeValue ); + const text = await response.text(); + headElements.set( attributeValue, { + tag, + text, + } ); + } + + const headElement = headElements.get( attributeValue ); + const element = document.createElement( tagName ); + element.innerText = headElement.text; + for ( const attr of headElement.tag.attributes ) { + element.setAttribute( attr.name, attr.value ); + } + headTags.push( element ); + } ) + ); + } + + return [ + document.querySelector( 'title' ), + ...document.querySelectorAll( 'style' ), + ...headTags, + ]; +}; diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 03d399338167ce..890f35fcf71660 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -1,7 +1,16 @@ +/** + * Internal dependencies + */ +import { fetchHeadAssets, updateHead } from './head'; /** * WordPress dependencies */ -import { store, privateApis, getConfig } from '@wordpress/interactivity'; +import { + store, + privateApis, + getConfig, + getElement, +} from '@wordpress/interactivity'; const { directivePrefix, @@ -16,8 +25,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 +57,49 @@ 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 ( navigationMode === 'fullPage' ) { + head = await fetchHeadAssets( dom, headElements ); + regions.body = vdom?.has( 'body' ) + ? vdom.get( '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 ( 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' ) { + 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,44 @@ 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 ( 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 } ) ) ); +const nextTick = ( fn ) => + new Promise( ( resolve ) => setTimeout( () => resolve( fn() ) ) ); + +// 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; + +// 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 = ''; @@ -128,23 +193,30 @@ export const { state, actions } = store( 'core/router', { * needed, and updates any interactive regions whose contents have * changed. It also creates a new entry in the browser session history. * - * @param {string} href The page href. - * @param {Object} [options] Options object. - * @param {boolean} [options.force] If true, it forces re-fetching the URL. - * @param {string} [options.html] HTML string to be used instead of fetching the requested URL. - * @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history. - * @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000. - * @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`. - * @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`. + * @param {string|Object} eventOrUrl The page href or the event handler in case it is used directly in a directive. + * @param {Object} [options] Options object. + * @param {boolean} [options.force] If true, it forces re-fetching the URL. + * @param {string} [options.html] HTML string to be used instead of fetching the requested URL. + * @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history. + * @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000. + * @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`. + * @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`. * * @return {Promise} Promise that resolves once the navigation is completed or aborted. */ - *navigate( href, options = {} ) { + *navigate( eventOrUrl, options = {} ) { const { clientNavigationDisabled } = getConfig(); - if ( clientNavigationDisabled ) { - yield forcePageReload( href ); + const { ref } = getElement(); + const url = typeof eventOrUrl === 'string' && eventOrUrl; + const event = eventOrUrl instanceof Event && eventOrUrl; + if ( + clientNavigationDisabled || + ! ( url || ( isValidLink( ref ) && isValidEvent( event ) ) ) + ) { + yield forcePageReload( url ); } - + if ( event ) event.preventDefault(); + const href = url ? url : ref.href; const pagePath = getPagePath( href ); const { navigation } = state; const { @@ -193,7 +265,7 @@ export const { state, actions } = store( 'core/router', { ! page.initialData?.config?.[ 'core/router' ] ?.clientNavigationDisabled ) { - renderRegions( page ); + yield nextTick( () => renderRegions( page ) ); window.history[ options.replace ? 'replaceState' : 'pushState' ]( {}, '', href ); @@ -218,6 +290,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 ); } @@ -229,15 +305,15 @@ export const { state, actions } = store( 'core/router', { * The function normalizes the URL and stores internally the fetch * promise, to avoid triggering a second fetch for an ongoing request. * - * @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|Object} eventOrUrl The page href or the event handler in case it is used directly in a directive. + * @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. */ - prefetch( url, options = {} ) { + prefetch( eventOrUrl, options = {} ) { + const url = typeof eventOrUrl === 'string' && eventOrUrl; const { clientNavigationDisabled } = getConfig(); - if ( clientNavigationDisabled ) return; + if ( clientNavigationDisabled || ! url ) return; const pagePath = getPagePath( url ); if ( options.force || ! pages.has( pagePath ) ) { diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index c46d717837c7cb..2aadcbf79a158e 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -19,7 +19,6 @@ module.exports = { entry: { index: './packages/interactivity', router: './packages/interactivity-router', - 'full-page-router': './packages/interactivity-router/src/full-page.js', navigation: './packages/block-library/src/navigation/view.js', query: './packages/block-library/src/query/view.js', image: './packages/block-library/src/image/view.js', From 79eac03c8331686fd0fcf925fdceb251bd211701 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 26 Mar 2024 19:19:21 +0100 Subject: [PATCH 41/61] Use vdom.get document.body --- packages/interactivity-router/src/index.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 890f35fcf71660..2e708c9d9edac1 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -62,9 +62,7 @@ const regionsToVdom = async ( dom, { vdom } = {} ) => { let head; if ( navigationMode === 'fullPage' ) { head = await fetchHeadAssets( dom, headElements ); - regions.body = vdom?.has( 'body' ) - ? vdom.get( 'body' ) - : toVdom( dom.body ); + regions.body = vdom ? vdom.get( document.body ) : toVdom( dom.body ); } if ( navigationMode === 'regionBased' ) { const attrName = `data-${ directivePrefix }-router-region`; From 5dcaa1de46ce465801cdcdee5a2fdabf80f5e67c Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 09:30:41 +0100 Subject: [PATCH 42/61] Remove nextTick function --- packages/interactivity-router/src/index.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 2e708c9d9edac1..395275d18c06dd 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -150,9 +150,6 @@ pages.set( Promise.resolve( regionsToVdom( document, { vdom: initialVdom } ) ) ); -const nextTick = ( fn ) => - new Promise( ( resolve ) => setTimeout( () => resolve( fn() ) ) ); - // Check if the link is valid for client-side navigation. const isValidLink = ( ref ) => ref && @@ -263,7 +260,7 @@ export const { state, actions } = store( 'core/router', { ! page.initialData?.config?.[ 'core/router' ] ?.clientNavigationDisabled ) { - yield nextTick( () => renderRegions( page ) ); + yield renderRegions( page ); window.history[ options.replace ? 'replaceState' : 'pushState' ]( {}, '', href ); From fb5f911e7d2729bd7ce47ab8e5690af31d80bfaf Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 09:31:52 +0100 Subject: [PATCH 43/61] Only call getElement when it is an event --- packages/interactivity-router/src/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 395275d18c06dd..687b9becaebc64 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -201,9 +201,13 @@ export const { state, actions } = store( 'core/router', { */ *navigate( eventOrUrl, options = {} ) { const { clientNavigationDisabled } = getConfig(); - const { ref } = getElement(); const url = typeof eventOrUrl === 'string' && eventOrUrl; const event = eventOrUrl instanceof Event && eventOrUrl; + let ref; + // The getElement() function can only be called when it is an event. + if ( event ) { + ref = getElement().ref; + } if ( clientNavigationDisabled || ! ( url || ( isValidLink( ref ) && isValidEvent( event ) ) ) From afa7127903c18632f0756d0ff6a2514e29e9802c Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 09:32:24 +0100 Subject: [PATCH 44/61] Allow instanceof URL in navigate --- packages/interactivity-router/src/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 687b9becaebc64..058412b32c80fd 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -201,7 +201,10 @@ export const { state, actions } = store( 'core/router', { */ *navigate( eventOrUrl, options = {} ) { const { clientNavigationDisabled } = getConfig(); - const url = typeof eventOrUrl === 'string' && eventOrUrl; + const url = + ( typeof eventOrUrl === 'string' || + eventOrUrl instanceof URL ) && + eventOrUrl; const event = eventOrUrl instanceof Event && eventOrUrl; let ref; // The getElement() function can only be called when it is an event. From 79768b6392c54fbdb4042c40b8b5a80eb34ae68b Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 09:39:33 +0100 Subject: [PATCH 45/61] Fix full page client-side navigation --- packages/interactivity-router/src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 058412b32c80fd..587a9ae623736b 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -81,7 +81,6 @@ const regionsToVdom = async ( dom, { vdom } = {} ) => { // Render all interactive regions contained in the given page. const renderRegions = ( page ) => { batch( () => { - populateInitialData( page.initialData ); 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 ); @@ -89,6 +88,7 @@ const renderRegions = ( page ) => { render( page.regions.body, fragment ); } if ( navigationMode === 'regionBased' ) { + populateInitialData( page.initialData ); const attrName = `data-${ directivePrefix }-router-region`; document .querySelectorAll( `[${ attrName }]` ) From 181614e418fa958f90077d7a3e9a409326126299 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 10:03:25 +0100 Subject: [PATCH 46/61] Use `wp_enqueue_scripts` hook --- lib/experimental/full-page-client-side-navigation.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/experimental/full-page-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php index 578deee7611603..56ffdec7e29289 100644 --- a/lib/experimental/full-page-client-side-navigation.php +++ b/lib/experimental/full-page-client-side-navigation.php @@ -3,15 +3,14 @@ * Registers full page client-side navigation option using the Interactivity API and adds the necessary directives. */ -// Set the navigation mode to full page client-side navigation. -wp_interactivity_config( 'core/router', array( 'navigationMode' => 'fullPage' ) ); - // Enqueue the interactivity router script. function _gutenberg_enqueue_interactivity_router() { + // Set the navigation mode to full page client-side navigation. + wp_interactivity_config( 'core/router', array( 'navigationMode' => 'fullPage' ) ); wp_enqueue_script_module( '@wordpress/interactivity-router' ); } -add_action( 'init', '_gutenberg_enqueue_interactivity_router', 20 ); +add_action( 'wp_enqueue_scripts', '_gutenberg_enqueue_interactivity_router' ); // Add directives to all links. // This should probably be done per site, not by default when this option is enabled. From 36e090173ee052f9d5bbb8a92eca3304e2df2bcb Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 10:12:19 +0100 Subject: [PATCH 47/61] Clean PHP code and docs --- .../full-page-client-side-navigation.php | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/lib/experimental/full-page-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php index 56ffdec7e29289..0f454bcbe41935 100644 --- a/lib/experimental/full-page-client-side-navigation.php +++ b/lib/experimental/full-page-client-side-navigation.php @@ -3,7 +3,9 @@ * Registers full page client-side navigation option using the Interactivity API and adds the necessary directives. */ -// Enqueue the interactivity router script. +/** + * Enqueue the interactivity router script. + */ function _gutenberg_enqueue_interactivity_router() { // Set the navigation mode to full page client-side navigation. wp_interactivity_config( 'core/router', array( 'navigationMode' => 'fullPage' ) ); @@ -12,9 +14,14 @@ function _gutenberg_enqueue_interactivity_router() { add_action( 'wp_enqueue_scripts', '_gutenberg_enqueue_interactivity_router' ); -// Add directives to all links. -// This should probably be done per site, not by default when this option is enabled. -function _gutenberg_add_enhanced_pagination( $parsed_block ) { +/** + * 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; } @@ -23,10 +30,17 @@ function _gutenberg_add_enhanced_pagination( $parsed_block ) { return $parsed_block; } -add_filter( 'render_block_data', '_gutenberg_add_enhanced_pagination', 10 ); +add_filter( 'render_block_data', '_gutenberg_add_enhanced_pagination_to_query_block' ); -// Add directives to all links. -// This should probably be done per site, not by default when this option is enabled. +/** + * 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 ); while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) { @@ -43,4 +57,4 @@ function _gutenberg_add_client_side_navigation_directives( $content ) { } // TODO: Explore moving this to the server directive processing. -add_filter( 'render_block', '_gutenberg_add_client_side_navigation_directives', 10, 2 ); +add_filter( 'render_block', '_gutenberg_add_client_side_navigation_directives' ); From 455b8d74e4b9fd2dabe3960b6d168a0791830546 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 10:13:29 +0100 Subject: [PATCH 48/61] Move internal dependencies after WordPress ones --- packages/interactivity-router/src/index.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 587a9ae623736b..d2512e822d764c 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -1,7 +1,3 @@ -/** - * Internal dependencies - */ -import { fetchHeadAssets, updateHead } from './head'; /** * WordPress dependencies */ @@ -12,6 +8,11 @@ import { getElement, } from '@wordpress/interactivity'; +/** + * Internal dependencies + */ +import { fetchHeadAssets, updateHead } from './head'; + const { directivePrefix, getRegionRootFragment, From a2c1a070da65542dcf10d45e728c4be40010c56a Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 10:23:56 +0100 Subject: [PATCH 49/61] Add initial JSDocs to head helper functions --- packages/interactivity-router/src/head.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/interactivity-router/src/head.js b/packages/interactivity-router/src/head.js index dc0bef1498396c..681c0262178269 100644 --- a/packages/interactivity-router/src/head.js +++ b/packages/interactivity-router/src/head.js @@ -1,4 +1,11 @@ -// Function to update only the necessary tags in the head. +/** + * Helper to update only the necessary tags in the head. + * + * @async + * @param {Array} newHead The head elements of the new page. + * + * @return {void} + */ export const updateHead = async ( newHead ) => { // Helper to get the tag id store in the cache. const getTagId = ( tag ) => tag.id || tag.outerHTML; @@ -30,7 +37,15 @@ export const updateHead = async ( newHead ) => { document.head.append( ...toAppend ); }; -// Fetch head assets of a new page. +/** + * Fetches and processes head assets (stylesheets and scripts) from a specified document. + * + * @async + * @param {Document} document 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 {void} + */ export const fetchHeadAssets = async ( document, headElements ) => { const headTags = []; const assets = [ From f7e5212961ad3909abc3b6665f4a1f06bc66a3f6 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 10:28:17 +0100 Subject: [PATCH 50/61] Allow URL instance in prefetch function --- packages/interactivity-router/src/index.js | 29 ++++++++++++---------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index d2512e822d764c..deb71789a270e5 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -189,14 +189,14 @@ export const { state, actions } = store( 'core/router', { * needed, and updates any interactive regions whose contents have * changed. It also creates a new entry in the browser session history. * - * @param {string|Object} eventOrUrl The page href or the event handler in case it is used directly in a directive. - * @param {Object} [options] Options object. - * @param {boolean} [options.force] If true, it forces re-fetching the URL. - * @param {string} [options.html] HTML string to be used instead of fetching the requested URL. - * @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history. - * @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000. - * @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`. - * @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`. + * @param {string|URL|Event} eventOrUrl The page href or the event handler in case it is used directly in a directive. + * @param {Object} [options] Options object. + * @param {boolean} [options.force] If true, it forces re-fetching the URL. + * @param {string} [options.html] HTML string to be used instead of fetching the requested URL. + * @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history. + * @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000. + * @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`. + * @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`. * * @return {Promise} Promise that resolves once the navigation is completed or aborted. */ @@ -308,13 +308,16 @@ export const { state, actions } = store( 'core/router', { * The function normalizes the URL and stores internally the fetch * promise, to avoid triggering a second fetch for an ongoing request. * - * @param {string|Object} eventOrUrl The page href or the event handler in case it is used directly in a directive. - * @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|URL|Event} eventOrUrl The page href or the event handler in case it is used directly in a directive. + * @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. */ prefetch( eventOrUrl, options = {} ) { - const url = typeof eventOrUrl === 'string' && eventOrUrl; + const url = + ( typeof eventOrUrl === 'string' || + eventOrUrl instanceof URL ) && + eventOrUrl; const { clientNavigationDisabled } = getConfig(); if ( clientNavigationDisabled || ! url ) return; From 8f09d2b56437d97838516ce60916670418550a4b Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 11:11:04 +0100 Subject: [PATCH 51/61] Properly support prefetch --- packages/interactivity-router/src/index.js | 49 ++++++++++++---------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index deb71789a270e5..e390f87fecd362 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -189,23 +189,20 @@ export const { state, actions } = store( 'core/router', { * needed, and updates any interactive regions whose contents have * changed. It also creates a new entry in the browser session history. * - * @param {string|URL|Event} eventOrUrl The page href or the event handler in case it is used directly in a directive. - * @param {Object} [options] Options object. - * @param {boolean} [options.force] If true, it forces re-fetching the URL. - * @param {string} [options.html] HTML string to be used instead of fetching the requested URL. - * @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history. - * @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000. - * @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`. - * @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`. + * @param {string|Event} eventOrUrl The page href or the event handler in case it is used directly in a directive. + * @param {Object} [options] Options object. + * @param {boolean} [options.force] If true, it forces re-fetching the URL. + * @param {string} [options.html] HTML string to be used instead of fetching the requested URL. + * @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history. + * @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000. + * @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`. + * @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`. * * @return {Promise} Promise that resolves once the navigation is completed or aborted. */ *navigate( eventOrUrl, options = {} ) { const { clientNavigationDisabled } = getConfig(); - const url = - ( typeof eventOrUrl === 'string' || - eventOrUrl instanceof URL ) && - eventOrUrl; + const url = ! ( eventOrUrl instanceof Event ) && eventOrUrl; const event = eventOrUrl instanceof Event && eventOrUrl; let ref; // The getElement() function can only be called when it is an event. @@ -308,20 +305,26 @@ export const { state, actions } = store( 'core/router', { * The function normalizes the URL and stores internally the fetch * promise, to avoid triggering a second fetch for an ongoing request. * - * @param {string|URL|Event} eventOrUrl The page href or the event handler in case it is used directly in a directive. - * @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|Event} eventOrUrl The page href or the event handler in case it is used directly in a directive. + * @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. */ prefetch( eventOrUrl, options = {} ) { - const url = - ( typeof eventOrUrl === 'string' || - eventOrUrl instanceof URL ) && - eventOrUrl; + const url = ! ( eventOrUrl instanceof Event ) && eventOrUrl; + const event = eventOrUrl instanceof Event && eventOrUrl; + let ref; + // The getElement() function can only be called when it is an event. + if ( event ) { + ref = getElement().ref; + } const { clientNavigationDisabled } = getConfig(); - if ( clientNavigationDisabled || ! url ) return; - - const pagePath = getPagePath( url ); + if ( + clientNavigationDisabled || + ! ( url || ( isValidLink( ref ) && isValidEvent( event ) ) ) + ) + return; + const pagePath = getPagePath( url ? url : ref.href ); if ( options.force || ! pages.has( pagePath ) ) { pages.set( pagePath, fetchPage( pagePath, options ) ); } From 743926f0160e985a4641c3b158f64700e8c656ea Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 27 Mar 2024 11:13:50 +0100 Subject: [PATCH 52/61] Fix JSDoc comments --- packages/interactivity-router/src/head.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/interactivity-router/src/head.js b/packages/interactivity-router/src/head.js index 681c0262178269..5f9ad67f51cc73 100644 --- a/packages/interactivity-router/src/head.js +++ b/packages/interactivity-router/src/head.js @@ -4,7 +4,6 @@ * @async * @param {Array} newHead The head elements of the new page. * - * @return {void} */ export const updateHead = async ( newHead ) => { // Helper to get the tag id store in the cache. @@ -44,7 +43,7 @@ export const updateHead = async ( newHead ) => { * @param {Document} document 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 {void} + * @return {HTMLElement[]} Returns an array of HTML elements representing the head assets. */ export const fetchHeadAssets = async ( document, headElements ) => { const headTags = []; From 44d087314d20c423f8a9fac38d1a6c340c859c71 Mon Sep 17 00:00:00 2001 From: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com> Date: Mon, 15 Apr 2024 15:58:40 +0200 Subject: [PATCH 53/61] Add Promise to JSDoc Co-authored-by: Michal --- packages/interactivity-router/src/head.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interactivity-router/src/head.js b/packages/interactivity-router/src/head.js index 5f9ad67f51cc73..3565b54925ca68 100644 --- a/packages/interactivity-router/src/head.js +++ b/packages/interactivity-router/src/head.js @@ -43,7 +43,7 @@ export const updateHead = async ( newHead ) => { * @param {Document} document 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 {HTMLElement[]} Returns an array of HTML elements representing the head assets. + * @return {Promise} Returns an array of HTML elements representing the head assets. */ export const fetchHeadAssets = async ( document, headElements ) => { const headTags = []; From eeb6491d31d32990401b2397d10ba2eb494afa87 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 16 Apr 2024 13:59:32 +0200 Subject: [PATCH 54/61] Specify experimental in query help message --- .../edit/inspector-controls/enhanced-pagination-control.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 cae45e3836802c..293baead3f5c62 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 @@ -20,7 +20,9 @@ export default function EnhancedPaginationControl( { let help = __( 'Browsing between pages requires a full page reload.' ); if ( fullPageClientSideNavigation ) { - help = __( 'Full page client-side navigation enabled.' ); + 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." From 46ce03183471cb5379a7907f20c04e9d9d23149b Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 16 Apr 2024 13:59:32 +0200 Subject: [PATCH 55/61] Wrap fullPage code in IS_GUTENBERG_PLUGIN check --- packages/interactivity-router/src/index.js | 40 +++++++++++++--------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index e390f87fecd362..f65449130a9c7b 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -61,9 +61,13 @@ const fetchPage = async ( url, { html } ) => { const regionsToVdom = async ( dom, { vdom } = {} ) => { const regions = {}; let head; - if ( navigationMode === 'fullPage' ) { - head = await fetchHeadAssets( dom, headElements ); - regions.body = vdom ? vdom.get( document.body ) : toVdom( dom.body ); + 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`; @@ -82,11 +86,13 @@ const regionsToVdom = async ( dom, { vdom } = {} ) => { // Render all interactive regions contained in the given page. const renderRegions = ( page ) => { batch( () => { - 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 ( 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 ); @@ -136,15 +142,17 @@ window.addEventListener( 'popstate', async () => { // 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 ( 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, +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 ); + await fetchHeadAssets( document, headElements ); + } } pages.set( getPagePath( window.location ), From 57ef83ceb0b51a09e522d5bea4218c5acb755c1d Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 16 Apr 2024 13:59:32 +0200 Subject: [PATCH 56/61] Use static variable to add body directive once --- lib/experimental/full-page-client-side-navigation.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/experimental/full-page-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php index 0f454bcbe41935..a452c30b63f6e1 100644 --- a/lib/experimental/full-page-client-side-navigation.php +++ b/lib/experimental/full-page-client-side-navigation.php @@ -53,7 +53,12 @@ function _gutenberg_add_client_side_navigation_directives( $content ) { } // Hack to add the necessary directives to the body tag. // TODO: Find a proper way to add directives to the body tag. - return (string) $p . ''; + 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. From a5b9e80f495208209ae76178a7bff2d999e949c1 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 16 Apr 2024 13:59:32 +0200 Subject: [PATCH 57/61] Wrap fetch in try/catch --- packages/interactivity-router/src/head.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/interactivity-router/src/head.js b/packages/interactivity-router/src/head.js index 3565b54925ca68..5bc9b9cf57fc5c 100644 --- a/packages/interactivity-router/src/head.js +++ b/packages/interactivity-router/src/head.js @@ -64,12 +64,17 @@ export const fetchHeadAssets = async ( document, headElements ) => { Array.from( tags ).map( async ( tag ) => { const attributeValue = tag.getAttribute( attribute ); if ( ! headElements.has( attributeValue ) ) { - const response = await fetch( attributeValue ); - const text = await response.text(); - headElements.set( attributeValue, { - tag, - text, - } ); + 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 ); From 4f9d728ccb8a401b4e388df6f979ed7fa2cf1bf6 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 16 Apr 2024 13:59:32 +0200 Subject: [PATCH 58/61] Rename document variable to doc --- packages/interactivity-router/src/head.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/interactivity-router/src/head.js b/packages/interactivity-router/src/head.js index 5bc9b9cf57fc5c..a8cce487e592e4 100644 --- a/packages/interactivity-router/src/head.js +++ b/packages/interactivity-router/src/head.js @@ -40,12 +40,12 @@ export const updateHead = async ( newHead ) => { * Fetches and processes head assets (stylesheets and scripts) from a specified document. * * @async - * @param {Document} document The document from which to fetch head assets. It should support standard DOM querying methods. + * @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 ( document, headElements ) => { +export const fetchHeadAssets = async ( doc, headElements ) => { const headTags = []; const assets = [ { @@ -57,7 +57,7 @@ export const fetchHeadAssets = async ( document, headElements ) => { ]; for ( const asset of assets ) { const { tagName, selector, attribute } = asset; - const tags = document.querySelectorAll( selector ); + const tags = doc.querySelectorAll( selector ); // Use Promise.all to wait for fetch to complete await Promise.all( @@ -78,7 +78,7 @@ export const fetchHeadAssets = async ( document, headElements ) => { } const headElement = headElements.get( attributeValue ); - const element = document.createElement( tagName ); + const element = doc.createElement( tagName ); element.innerText = headElement.text; for ( const attr of headElement.tag.attributes ) { element.setAttribute( attr.name, attr.value ); @@ -89,8 +89,8 @@ export const fetchHeadAssets = async ( document, headElements ) => { } return [ - document.querySelector( 'title' ), - ...document.querySelectorAll( 'style' ), + doc.querySelector( 'title' ), + ...doc.querySelectorAll( 'style' ), ...headTags, ]; }; From d7e6ddb4bf5528013e87eadddcd090172a108740 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Tue, 16 Apr 2024 17:18:13 +0200 Subject: [PATCH 59/61] Prevent client navigation in admin links --- packages/interactivity-router/src/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index f65449130a9c7b..191e0937acdc2b 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -165,7 +165,10 @@ const isValidLink = ( ref ) => ref instanceof window.HTMLAnchorElement && ref.href && ( ! ref.target || ref.target === '_self' ) && - ref.origin === window.location.origin; + ref.origin === window.location.origin && + ! ref.pathname.startsWith( '/wp-admin' ) && + ! ref.pathname.startsWith( '/wp-login.php' ) && + ! new URL( ref.href ).searchParams.get( '_wpnonce' ); // Check if the event is valid for client-side navigation. const isValidEvent = ( event ) => From d72cd023eae40645e8e787f24d91f515b12ce4ee Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 17 Apr 2024 11:58:23 +0200 Subject: [PATCH 60/61] Add event listeners for navigate and prefetch in JS --- .../full-page-client-side-navigation.php | 8 -- packages/interactivity-router/src/index.js | 99 ++++++++++--------- 2 files changed, 52 insertions(+), 55 deletions(-) diff --git a/lib/experimental/full-page-client-side-navigation.php b/lib/experimental/full-page-client-side-navigation.php index a452c30b63f6e1..ebfddf4aaf4369 100644 --- a/lib/experimental/full-page-client-side-navigation.php +++ b/lib/experimental/full-page-client-side-navigation.php @@ -43,14 +43,6 @@ function _gutenberg_add_enhanced_pagination_to_query_block( $parsed_block ) { */ function _gutenberg_add_client_side_navigation_directives( $content ) { $p = new WP_HTML_Tag_Processor( $content ); - while ( $p->next_tag( array( 'tag_name' => 'a' ) ) ) { - if ( empty( $p->get_attribute( 'data-wp-on--click' ) ) ) { - $p->set_attribute( 'data-wp-on--click', 'core/router::actions.navigate' ); - } - if ( empty( $p->get_attribute( 'data-wp-on--mouseenter' ) ) ) { - $p->set_attribute( 'data-wp-on--mouseenter', 'core/router::actions.prefetch' ); - } - } // 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; diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index 191e0937acdc2b..de951f7e7d9993 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -1,12 +1,7 @@ /** * WordPress dependencies */ -import { - store, - privateApis, - getConfig, - getElement, -} from '@wordpress/interactivity'; +import { store, privateApis, getConfig } from '@wordpress/interactivity'; /** * Internal dependencies @@ -200,34 +195,23 @@ export const { state, actions } = store( 'core/router', { * needed, and updates any interactive regions whose contents have * changed. It also creates a new entry in the browser session history. * - * @param {string|Event} eventOrUrl The page href or the event handler in case it is used directly in a directive. - * @param {Object} [options] Options object. - * @param {boolean} [options.force] If true, it forces re-fetching the URL. - * @param {string} [options.html] HTML string to be used instead of fetching the requested URL. - * @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history. - * @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000. - * @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`. - * @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`. + * @param {string} href The page href. + * @param {Object} [options] Options object. + * @param {boolean} [options.force] If true, it forces re-fetching the URL. + * @param {string} [options.html] HTML string to be used instead of fetching the requested URL. + * @param {boolean} [options.replace] If true, it replaces the current entry in the browser session history. + * @param {number} [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000. + * @param {boolean} [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`. + * @param {boolean} [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`. * * @return {Promise} Promise that resolves once the navigation is completed or aborted. */ - *navigate( eventOrUrl, options = {} ) { + *navigate( href, options = {} ) { const { clientNavigationDisabled } = getConfig(); - const url = ! ( eventOrUrl instanceof Event ) && eventOrUrl; - const event = eventOrUrl instanceof Event && eventOrUrl; - let ref; - // The getElement() function can only be called when it is an event. - if ( event ) { - ref = getElement().ref; - } - if ( - clientNavigationDisabled || - ! ( url || ( isValidLink( ref ) && isValidEvent( event ) ) ) - ) { - yield forcePageReload( url ); + if ( clientNavigationDisabled ) { + yield forcePageReload( href ); } - if ( event ) event.preventDefault(); - const href = url ? url : ref.href; + const pagePath = getPagePath( href ); const { navigation } = state; const { @@ -316,29 +300,50 @@ export const { state, actions } = store( 'core/router', { * The function normalizes the URL and stores internally the fetch * promise, to avoid triggering a second fetch for an ongoing request. * - * @param {string|Event} eventOrUrl The page href or the event handler in case it is used directly in a directive. - * @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} 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. */ - prefetch( eventOrUrl, options = {} ) { - const url = ! ( eventOrUrl instanceof Event ) && eventOrUrl; - const event = eventOrUrl instanceof Event && eventOrUrl; - let ref; - // The getElement() function can only be called when it is an event. - if ( event ) { - ref = getElement().ref; - } + prefetch( url, options = {} ) { const { clientNavigationDisabled } = getConfig(); - if ( - clientNavigationDisabled || - ! ( url || ( isValidLink( ref ) && isValidEvent( event ) ) ) - ) - return; - const pagePath = getPagePath( url ? url : ref.href ); + if ( clientNavigationDisabled ) return; + + const pagePath = getPagePath( url ); if ( options.force || ! pages.has( pagePath ) ) { pages.set( pagePath, fetchPage( pagePath, options ) ); } }, }, } ); + +// 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 + ); + } +} From 745e675abd093713ba16d2fbc0d76c00e5831335 Mon Sep 17 00:00:00 2001 From: Mario Santos Date: Wed, 17 Apr 2024 11:59:24 +0200 Subject: [PATCH 61/61] Add check for anchor links of the same page --- packages/interactivity-router/src/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index de951f7e7d9993..03d75bafa82f44 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -163,7 +163,8 @@ const isValidLink = ( ref ) => ref.origin === window.location.origin && ! ref.pathname.startsWith( '/wp-admin' ) && ! ref.pathname.startsWith( '/wp-login.php' ) && - ! new URL( ref.href ).searchParams.get( '_wpnonce' ); + ! ref.getAttribute( 'href' ).startsWith( '#' ) && + ! new URL( ref.href ).searchParams.has( '_wpnonce' ); // Check if the event is valid for client-side navigation. const isValidEvent = ( event ) =>