Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiment: Add full page client-side navigation experiment setting #59707

Merged
merged 61 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6a1b29a
Add Gutenberg experiment option
SantosGuillamot Mar 5, 2024
a5b6dd0
Add config option and directives in PHP
SantosGuillamot Mar 5, 2024
cf8e72b
Load full CSN logic conditionally
SantosGuillamot Mar 5, 2024
8ddc122
Add `data-wp-interactive` root
SantosGuillamot Mar 5, 2024
32cd62c
Change variables names
SantosGuillamot Mar 5, 2024
cc25473
Register different scripts if the experiment is enabled
SantosGuillamot Mar 6, 2024
dffa452
Require experimental code once interactivity is loaded
SantosGuillamot Mar 6, 2024
7412f98
Change experiment namespace
SantosGuillamot Mar 6, 2024
f92355d
Move full-csn logic to interactivity-router
SantosGuillamot Mar 6, 2024
04b94a9
Add proper support for prefetch
SantosGuillamot Mar 6, 2024
ca90d0a
Adapt query loop
SantosGuillamot Mar 6, 2024
34e6b22
Fix modules error after csn
SantosGuillamot Mar 6, 2024
4f67b70
Add initial page to cache
SantosGuillamot Mar 6, 2024
592b842
WIP: Fix scripts loading after csn
SantosGuillamot Mar 7, 2024
1593483
Simplify code
SantosGuillamot Mar 7, 2024
afda3e6
Adapt query loop block
SantosGuillamot Mar 8, 2024
195e7e7
Fix full CSN when queryID is not defined
SantosGuillamot Mar 8, 2024
c8348ab
Remove preload logic
SantosGuillamot Mar 8, 2024
69a70d6
Change full csn conditional in query
SantosGuillamot Mar 8, 2024
9b2c853
Use only one app in the body
SantosGuillamot Mar 8, 2024
8f16b30
Use getRegionRootFragment and initialVdom
SantosGuillamot Mar 8, 2024
7f458b1
Adapt all query loop blocks
SantosGuillamot Mar 8, 2024
1d96c64
Add key to query loop block
SantosGuillamot Mar 8, 2024
63c81a6
Add `yield` to query block actions
SantosGuillamot Mar 8, 2024
652a2a2
Revert conditional scripts depending on the experiment
SantosGuillamot Mar 22, 2024
442248c
Register `interactivity-router-full-client-side-navigation` in the ex…
SantosGuillamot Mar 22, 2024
0f3bc44
Load router conditionally in query loop
SantosGuillamot Mar 22, 2024
62f6948
Scroll to anchor
SantosGuillamot Mar 22, 2024
a26bfa7
Remove unnecessary empty conditional
SantosGuillamot Mar 22, 2024
a5a7279
Fix back and forward buttons
SantosGuillamot Mar 22, 2024
f984294
Fix query loop
SantosGuillamot Mar 22, 2024
0f7a8db
Remove unnecessary conditional
SantosGuillamot Mar 22, 2024
cd7a112
Use full page client-side navigation naming
SantosGuillamot Mar 25, 2024
dd2b73e
Change comments
SantosGuillamot Mar 25, 2024
7eef1ce
Use render_block_data to change query attribute
SantosGuillamot Mar 25, 2024
f0fecee
Refactor JavaScript logic
SantosGuillamot Mar 25, 2024
7081358
Remove unused variable
SantosGuillamot Mar 26, 2024
1d00a3c
Revert changes in query block view.js file
SantosGuillamot Mar 26, 2024
a318179
Remove unnecessary export from interactivity
SantosGuillamot Mar 26, 2024
2138f0d
Move logic to the existing router
SantosGuillamot Mar 26, 2024
79eac03
Use vdom.get document.body
SantosGuillamot Mar 26, 2024
5dcaa1d
Remove nextTick function
SantosGuillamot Mar 27, 2024
fb5f911
Only call getElement when it is an event
SantosGuillamot Mar 27, 2024
afa7127
Allow instanceof URL in navigate
SantosGuillamot Mar 27, 2024
79768b6
Fix full page client-side navigation
SantosGuillamot Mar 27, 2024
181614e
Use `wp_enqueue_scripts` hook
SantosGuillamot Mar 27, 2024
36e0901
Clean PHP code and docs
SantosGuillamot Mar 27, 2024
455b8d7
Move internal dependencies after WordPress ones
SantosGuillamot Mar 27, 2024
a2c1a07
Add initial JSDocs to head helper functions
SantosGuillamot Mar 27, 2024
f7e5212
Allow URL instance in prefetch function
SantosGuillamot Mar 27, 2024
8f09d2b
Properly support prefetch
SantosGuillamot Mar 27, 2024
743926f
Fix JSDoc comments
SantosGuillamot Mar 27, 2024
44d0873
Add Promise to JSDoc
SantosGuillamot Apr 15, 2024
eeb6491
Specify experimental in query help message
SantosGuillamot Apr 16, 2024
46ce031
Wrap fullPage code in IS_GUTENBERG_PLUGIN check
SantosGuillamot Apr 16, 2024
57ef83c
Use static variable to add body directive once
SantosGuillamot Apr 16, 2024
a5b9e80
Wrap fetch in try/catch
SantosGuillamot Apr 16, 2024
4f9d728
Rename document variable to doc
SantosGuillamot Apr 16, 2024
d7e6ddb
Prevent client navigation in admin links
SantosGuillamot Apr 16, 2024
d72cd02
Add event listeners for navigate and prefetch in JS
SantosGuillamot Apr 17, 2024
745e675
Add check for anchor links of the same page
SantosGuillamot Apr 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/experimental/editor-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ function gutenberg_enable_experiments() {
if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) {
wp_add_inline_script( 'wp-block-library', 'window.__experimentalDisableTinymce = true', 'before' );
}
if ( gutenberg_is_experiment_enabled( 'gutenberg-full-page-client-side-navigation' ) ) {
wp_add_inline_script( 'wp-block-library', 'window.__experimentalFullPageClientSideNavigation = true', 'before' );
}
}

add_action( 'admin_init', 'gutenberg_enable_experiments' );
Expand Down
57 changes: 57 additions & 0 deletions lib/experimental/full-page-client-side-navigation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php
/**
* Registers full page client-side navigation option using the Interactivity API and adds the necessary directives.
*/

/**
* 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( 'wp_enqueue_scripts', '_gutenberg_enqueue_interactivity_router' );

/**
* Set enhancedPagination attribute for query loop when the experiment is enabled.
*
* @param array $parsed_block The parsed block.
*
* @return array The same parsed block with the modified attribute.
*/
function _gutenberg_add_enhanced_pagination_to_query_block( $parsed_block ) {
if ( 'core/query' !== $parsed_block['blockName'] ) {
return $parsed_block;
}

$parsed_block['attrs']['enhancedPagination'] = true;
return $parsed_block;
}

add_filter( 'render_block_data', '_gutenberg_add_enhanced_pagination_to_query_block' );

/**
* Add directives to all links.
*
* Note: This should probably be done per site, not by default when this option is enabled.
*
* @param array $content The block content.
*
* @return array The same block content with the directives needed.
*/
function _gutenberg_add_client_side_navigation_directives( $content ) {
$p = new WP_HTML_Tag_Processor( $content );
// Hack to add the necessary directives to the body tag.
// TODO: Find a proper way to add directives to the body tag.
static $body_interactive_added;
if ( ! $body_interactive_added ) {
$body_interactive_added = true;
return (string) $p . '<body data-wp-interactive="core/experimental" data-wp-context="{}">';
}
return (string) $p;
}

// TODO: Explore moving this to the server directive processing.
add_filter( 'render_block', '_gutenberg_add_client_side_navigation_directives' );
12 changes: 12 additions & 0 deletions lib/experiments-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ function gutenberg_initialize_experiments_settings() {
)
);

add_settings_field(
'gutenberg-full-page-client-side-navigation',
__( 'Enable full page client-side navigation', 'gutenberg' ),
'gutenberg_display_experiment_field',
'gutenberg-experiments',
'gutenberg_experiments_section',
array(
'label' => __( 'Enable full page client-side navigation using the Interactivity API', 'gutenberg' ),
'id' => 'gutenberg-full-page-client-side-navigation',
)
);

register_setting(
'gutenberg-experiments',
'gutenberg-experiments'
Expand Down
3 changes: 3 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/demo.php';
require __DIR__ . '/experiments-page.php';
require __DIR__ . '/interactivity-api.php';
if ( gutenberg_is_experiment_enabled( 'gutenberg-full-page-client-side-navigation' ) ) {
require __DIR__ . '/experimental/full-page-client-side-navigation.php';
}

// Copied package PHP files.
if ( is_dir( __DIR__ . '/../build/style-engine' ) ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ export default function EnhancedPaginationModal( {
useUnsupportedBlocks( clientId );

useEffect( () => {
if ( enhancedPagination && hasUnsupportedBlocks ) {
if (
enhancedPagination &&
hasUnsupportedBlocks &&
! window.__experimentalFullPageClientSideNavigation
) {
setAttributes( { enhancedPagination: false } );
setOpen( true );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@ export default function EnhancedPaginationControl( {
clientId,
} ) {
const { hasUnsupportedBlocks } = useUnsupportedBlocks( clientId );
const fullPageClientSideNavigation =
window.__experimentalFullPageClientSideNavigation;

let help = __( 'Browsing between pages requires a full page reload.' );
if ( enhancedPagination ) {
if ( fullPageClientSideNavigation ) {
help = __(
'Experimental full-page client-side navigation setting enabled.'
);
} else if ( enhancedPagination ) {
help = __(
"Browsing between pages won't require a full page reload, unless non-compatible blocks are detected."
);
Expand All @@ -32,8 +38,12 @@ export default function EnhancedPaginationControl( {
<ToggleControl
label={ __( 'Force page reload' ) }
help={ help }
checked={ ! enhancedPagination }
disabled={ hasUnsupportedBlocks }
checked={
! enhancedPagination && ! fullPageClientSideNavigation
}
disabled={
hasUnsupportedBlocks || fullPageClientSideNavigation
}
onChange={ ( value ) => {
setAttributes( {
enhancedPagination: ! value,
Expand Down
2 changes: 1 addition & 1 deletion packages/block-library/src/query/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ function render_block_core_query( $attributes, $content, $block ) {
// Add the necessary directives.
$p->set_attribute( 'data-wp-interactive', 'core/query' );
$p->set_attribute( 'data-wp-router-region', 'query-' . $attributes['queryId'] );
$p->set_attribute( 'data-wp-init', 'callbacks.setQueryRef' );
$p->set_attribute( 'data-wp-context', '{}' );
$p->set_attribute( 'data-wp-key', $attributes['queryId'] );
gziolo marked this conversation as resolved.
Show resolved Hide resolved
$content = $p->get_updated_html();
}
}
Expand Down
96 changes: 96 additions & 0 deletions packages/interactivity-router/src/head.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Helper to update only the necessary tags in the head.
*
* @async
* @param {Array} newHead The head elements of the new page.
*
*/
export const updateHead = async ( newHead ) => {
// Helper to get the tag id store in the cache.
const getTagId = ( tag ) => tag.id || tag.outerHTML;

// Map incoming head tags by their content.
const newHeadMap = new Map();
for ( const child of newHead ) {
newHeadMap.set( getTagId( child ), child );
}

const toRemove = [];

// Detect nodes that should be added or removed.
for ( const child of document.head.children ) {
const id = getTagId( child );
// Always remove styles and links as they might change.
if ( child.nodeName === 'LINK' || child.nodeName === 'STYLE' )
toRemove.push( child );
Comment on lines +23 to +25
Copy link
Member

Choose a reason for hiding this comment

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

If the stylesheet remains the same, I think it should be left as-is. Otherwise, removing a stylesheet and then adding it right back in will likely cause a performance problem.

Example: https://mousy-citrine-lotus.glitch.me/

Note there is a flash of unstyled content. It also causes layout and style recalculation:

image

Copy link
Member

Choose a reason for hiding this comment

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

Would be nice to be able to reuse DOM diffing from Preact here. (Not sure if that's possible.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a follow-up task to this list to explore this after this experiment is merged.

Copy link
Member

Choose a reason for hiding this comment

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

I tried using Preact at first, but if the scripts and styles change order or new elements appear in the head, Preact gets confused and deletes and re-adds everything.

I think it will be simpler and more stable if we control it ourselves.

else if ( newHeadMap.has( id ) ) newHeadMap.delete( id );
else if ( child.nodeName !== 'SCRIPT' && child.nodeName !== 'META' )
toRemove.push( child );
}

// Prepare new assets.
const toAppend = [ ...newHeadMap.values() ];

// Apply the changes.
toRemove.forEach( ( n ) => n.remove() );
document.head.append( ...toAppend );
};

/**
* Fetches and processes head assets (stylesheets and scripts) from a specified document.
*
* @async
* @param {Document} doc The document from which to fetch head assets. It should support standard DOM querying methods.
* @param {Map} headElements A map of head elements to modify tracking the URLs of already processed assets to avoid duplicates.
*
* @return {Promise<HTMLElement[]>} Returns an array of HTML elements representing the head assets.
*/
export const fetchHeadAssets = async ( doc, headElements ) => {
const headTags = [];
const assets = [
{
tagName: 'style',
selector: 'link[rel=stylesheet]',
attribute: 'href',
},
{ tagName: 'script', selector: 'script[src]', attribute: 'src' },
];
for ( const asset of assets ) {
const { tagName, selector, attribute } = asset;
const tags = doc.querySelectorAll( selector );

// Use Promise.all to wait for fetch to complete
await Promise.all(
Array.from( tags ).map( async ( tag ) => {
const attributeValue = tag.getAttribute( attribute );
if ( ! headElements.has( attributeValue ) ) {
try {
const response = await fetch( attributeValue );
const text = await response.text();
headElements.set( attributeValue, {
tag,
text,
} );
} catch ( e ) {
// eslint-disable-next-line no-console
console.error( e );
}
}

const headElement = headElements.get( attributeValue );
const element = doc.createElement( tagName );
element.innerText = headElement.text;
Copy link
Contributor

@michalczaplinski michalczaplinski Apr 3, 2024

Choose a reason for hiding this comment

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

For styles we should use element.textContent because innerText might cause a
reflow

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added it as a follow-up to explore after this experiment.

for ( const attr of headElement.tag.attributes ) {
element.setAttribute( attr.name, attr.value );
}
headTags.push( element );
} )
);
}

return [
doc.querySelector( 'title' ),
...doc.querySelectorAll( 'style' ),
...headTags,
];
};
Loading
Loading