-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Navigation: preload more API requests #35402
Changes from all commits
7250125
8c2df90
eb69f2d
482eb34
d06b9e0
b42deac
55a460d
282e7e0
8b55372
630dc7a
24daf14
7a85496
254c788
c8b4eed
14e4d0f
6f526a2
94b412e
f071d8d
b206204
61c150b
436e7c7
535f4e3
a34a2ee
c16d3ee
6ccc620
24ba0b5
87f5c98
c58002e
f37e481
368367b
79a7e3a
fcfdc8c
4d5878b
6fc54fb
83a1edc
a62c907
612d17b
75519d9
d9f51e2
eedbff5
78b501e
41ea183
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,23 +21,84 @@ class="edit-navigation" | |
} | ||
|
||
/** | ||
* Initialize the Gutenberg Navigation page. | ||
* This function returns an url for the /__experimental/menus endpoint | ||
* | ||
* @since 7.8.0 | ||
* @since 11.8.0 | ||
* | ||
* @param int $results_per_page Results per page. | ||
* @return string | ||
*/ | ||
function gutenberg_navigation_get_menus_endpoint( $results_per_page = 100 ) { | ||
return '/__experimental/menus?' . build_query( | ||
array( | ||
'per_page' => $results_per_page, | ||
'context' => 'edit', | ||
'_locale' => 'user', | ||
) | ||
); | ||
} | ||
|
||
/** | ||
* This function returns an url for the /__experimental/menu-items endpoint | ||
* | ||
* @since 11.8.0 | ||
* | ||
* @param int $menu_id Menu ID. | ||
* @param int $results_per_page Results per page. | ||
* @return string | ||
*/ | ||
function gutenberg_navigation_get_menu_items_endpoint( $menu_id, $results_per_page = 100 ) { | ||
return '/__experimental/menu-items?' . build_query( | ||
array( | ||
'context' => 'edit', | ||
'menus' => $menu_id, | ||
'per_page' => $results_per_page, | ||
'_locale' => 'user', | ||
) | ||
); | ||
} | ||
|
||
/** | ||
* This function returns an url for the /wp/v2/types endpoint | ||
* | ||
* @since 11.8.0 | ||
* | ||
* @return string | ||
*/ | ||
function gutenberg_navigation_get_types_endpoint() { | ||
return '/wp/v2/types?' . build_query( | ||
array( | ||
'context' => 'edit', | ||
) | ||
); | ||
} | ||
|
||
/** | ||
* Initialize the Gutenberg Navigation page. | ||
* | ||
* @param string $hook Page. | ||
* @since 7.8.0 | ||
*/ | ||
function gutenberg_navigation_init( $hook ) { | ||
if ( 'gutenberg_page_gutenberg-navigation' !== $hook ) { | ||
return; | ||
return; | ||
} | ||
|
||
$menus = wp_get_nav_menus(); | ||
$first_menu_id = ! empty( $menus ) ? $menus[0]->term_id : null; | ||
|
||
$preload_paths = array( | ||
'/__experimental/menu-locations', | ||
array( '/wp/v2/pages', 'OPTIONS' ), | ||
array( '/wp/v2/posts', 'OPTIONS' ), | ||
gutenberg_navigation_get_menus_endpoint(), | ||
gutenberg_navigation_get_types_endpoint(), | ||
); | ||
|
||
if ( $first_menu_id ) { | ||
spacedmonkey marked this conversation as resolved.
Show resolved
Hide resolved
|
||
$preload_paths[] = gutenberg_navigation_get_menu_items_endpoint( $first_menu_id ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see there was already a resolved conversation about this, but I think it might be worth creating an issue to explore how the selected menu might be preloaded. A thought I had is that we could pass the menu id as a query string Or alternatively we could help with #15105, at which point the preference would be stored in the database rather than localStorage, and our server-side code would be able to use the value. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That PR will be a game-changer if it works as you described. Then we could just use |
||
} | ||
|
||
$settings = array_merge( | ||
gutenberg_get_default_block_editor_settings(), | ||
array( | ||
|
@@ -82,3 +143,41 @@ function gutenberg_navigation_editor_load_block_editor_scripts_and_styles( $is_b | |
} | ||
|
||
add_filter( 'should_load_block_editor_scripts_and_styles', 'gutenberg_navigation_editor_load_block_editor_scripts_and_styles' ); | ||
|
||
/** | ||
* This function removes menu-related data from the "common" preloading middleware and calls | ||
* createMenuPreloadingMiddleware middleware because we need to use custom preloading logic for menus. | ||
* | ||
* @param Array $preload_data Array containing the preloaded data. | ||
* @param string $context Current editor name. | ||
* @return array Filtered preload data. | ||
*/ | ||
function gutenberg_navigation_editor_preload_menus( $preload_data, $context ) { | ||
if ( 'navigation_editor' !== $context ) { | ||
return $preload_data; | ||
} | ||
|
||
$menus_data_path = gutenberg_navigation_get_menus_endpoint(); | ||
$menus_data = array(); | ||
if ( ! empty( $preload_data[ $menus_data_path ] ) ) { | ||
$menus_data = array( $menus_data_path => $preload_data[ $menus_data_path ] ); | ||
} | ||
|
||
if ( ! $menus_data ) { | ||
return $preload_data; | ||
} | ||
|
||
wp_add_inline_script( | ||
'wp-edit-navigation', | ||
sprintf( | ||
'wp.apiFetch.use( wp.editNavigation.__unstableCreateMenuPreloadingMiddleware( %s ) );', | ||
wp_json_encode( $menus_data ) | ||
), | ||
'after' | ||
); | ||
|
||
unset( $preload_data[ $menus_data_path ] ); | ||
return $preload_data; | ||
} | ||
|
||
add_filter( 'block_editor_preload_data', 'gutenberg_navigation_editor_preload_menus', 10, 2 ); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
/** | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we document this function with
🙏 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 24db010 |
||
* The purpose of this function is to create a middleware that is responsible for preloading menu-related data. | ||
* It uses data that is returned from the /__experimental/menus endpoint for requests | ||
* to the /__experimental/menu/<menuId> endpoint, because the data is the same. | ||
* This way, we can avoid making additional REST API requests. | ||
* This middleware can be removed if/when we implement caching at the wordpress/core-data level. | ||
* | ||
* @param {Object} preloadedData | ||
* @return {Function} Preloading middleware. | ||
*/ | ||
export function createMenuPreloadingMiddleware( preloadedData ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Even though It seems like a more general problem that when we preload a request that lists items the individual item can also be considered preloaded, regardless of the REST resource. It might be something that applies to posts, pages and other resources too. What do you think? I'm not against merging this PR with a special case for menus right now, but in the long run I could see this as a candidate for removal if we come up with a more generalised preloading system. Right now it might be worth exporting this as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd wait until #15105 gets merged and remove There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #15105 is an issue and seems unrelated. Did you mean another number? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @adamziel The related discussion is here - #35402 (comment), the background is that we'd be able to preload the selected menu item if it were stored in the database instead of local storage, and I guess that removes the use case for this function? Though I still feel like we'll be in a situation where we need to load all the menus for the menu switcher. @anton-vlasenko I wouldn't worry too much about waiting for #15105 / #19177 because it has been a very long-running issue, but it'd be good to help it along if we can. If we can ship this PR by renaming the function I think that's fine 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It seems to be related. If it allows to store There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for the feedback, @talldan. |
||
const cache = Object.keys( preloadedData ).reduce( ( result, path ) => { | ||
result[ getStablePath( path ) ] = preloadedData[ path ]; | ||
return result; | ||
}, /** @type {Record<string, any>} */ ( {} ) ); | ||
|
||
let menusDataLoaded = false; | ||
let menuDataLoaded = false; | ||
|
||
return ( options, next ) => { | ||
const { parse = true } = options; | ||
if ( 'string' !== typeof options.path ) { | ||
return next( options ); | ||
} | ||
|
||
const method = options.method || 'GET'; | ||
if ( 'GET' !== method ) { | ||
return next( options ); | ||
} | ||
|
||
const path = getStablePath( options.path ); | ||
if ( ! menusDataLoaded && cache[ path ] ) { | ||
menusDataLoaded = true; | ||
return sendSuccessResponse( cache[ path ], parse ); | ||
} | ||
|
||
if ( menuDataLoaded ) { | ||
return next( options ); | ||
} | ||
|
||
const matches = path.match( | ||
/^\/__experimental\/menus\/(\d+)\?context=edit$/ | ||
); | ||
if ( ! matches ) { | ||
return next( options ); | ||
} | ||
|
||
const key = Object.keys( cache )?.[ 0 ]; | ||
const menuData = cache[ key ]?.body; | ||
if ( ! menuData ) { | ||
return next( options ); | ||
} | ||
|
||
const menuId = parseInt( matches[ 1 ] ); | ||
const menu = menuData.filter( ( { id } ) => id === menuId ); | ||
|
||
if ( menu.length > 0 ) { | ||
menuDataLoaded = true; | ||
// We don't have headers because we "emulate" this request | ||
return sendSuccessResponse( | ||
{ body: menu[ 0 ], headers: {} }, | ||
parse | ||
); | ||
} | ||
|
||
return next( options ); | ||
}; | ||
} | ||
|
||
/** | ||
* This is a helper function that sends a success response. | ||
* | ||
* @param {Object} responseData An object with the menu data | ||
* @param {boolean} parse A boolean that controls whether to send a response or just the response data | ||
* @return {Object} Resolved promise | ||
*/ | ||
function sendSuccessResponse( responseData, parse ) { | ||
return Promise.resolve( | ||
parse | ||
? responseData.body | ||
: new window.Response( JSON.stringify( responseData.body ), { | ||
status: 200, | ||
statusText: 'OK', | ||
headers: responseData.headers, | ||
} ) | ||
); | ||
} | ||
|
||
/** | ||
* Given a path, returns a normalized path where equal query parameter values | ||
* will be treated as identical, regardless of order they appear in the original | ||
* text. | ||
* | ||
* @param {string} path Original path. | ||
* | ||
* @return {string} Normalized path. | ||
*/ | ||
export function getStablePath( path ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at the way this function works, it's worth checking some of the utils in the Some of this code might also be a good candidate for the Might be a good thing for a separate follow-up PR. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Awesome! I've created a GH issue for this. #35799 |
||
const splitted = path.split( '?' ); | ||
const query = splitted[ 1 ]; | ||
const base = splitted[ 0 ]; | ||
if ( ! query ) { | ||
return base; | ||
} | ||
|
||
// 'b=1&c=2&a=5' | ||
return ( | ||
base + | ||
'?' + | ||
query | ||
// [ 'b=1', 'c=2', 'a=5' ] | ||
.split( '&' ) | ||
// [ [ 'b, '1' ], [ 'c', '2' ], [ 'a', '5' ] ] | ||
.map( ( entry ) => entry.split( '=' ) ) | ||
// [ [ 'a', '5' ], [ 'b, '1' ], [ 'c', '2' ] ] | ||
.sort( ( a, b ) => a[ 0 ].localeCompare( b[ 0 ] ) ) | ||
// [ 'a=5', 'b=1', 'c=2' ] | ||
.map( ( pair ) => pair.join( '=' ) ) | ||
// 'a=5&b=1&c=2' | ||
.join( '&' ) | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
<?php | ||
/** | ||
* This class is supposed to test the functionality of the navigation-page.php | ||
* | ||
* @package Gutenberg | ||
*/ | ||
|
||
class WP_Navigation_Page_Test extends WP_UnitTestCase { | ||
/** | ||
* @var WP_Navigation_Page_Test_Callback | ||
*/ | ||
private $callback; | ||
|
||
public function setUp() { | ||
parent::setUp(); | ||
$this->callback = $this->createMock( WP_Navigation_Page_Test_Callback::class ); | ||
add_filter( 'navigation_editor_preload_paths', array( $this->callback, 'preload_paths_callback' ) ); | ||
add_filter( 'wp_get_nav_menus', array( $this->callback, 'wp_nav_menus_callback' ) ); | ||
} | ||
|
||
public function tearDown() { | ||
parent::tearDown(); | ||
remove_filter( 'navigation_editor_preload_paths', array( $this->callback, 'preload_paths_callback' ) ); | ||
remove_filter( 'wp_get_nav_menus', array( $this->callback, 'wp_nav_menus_callback' ) ); | ||
} | ||
|
||
public function test_gutenberg_navigation_init_function_generates_correct_preload_paths() { | ||
$menu_id = mt_rand( 1, 1000 ); | ||
$expected_preload_paths = array( | ||
'/__experimental/menu-locations', | ||
array( | ||
'/wp/v2/pages', | ||
'OPTIONS', | ||
), | ||
array( | ||
'/wp/v2/posts', | ||
'OPTIONS', | ||
), | ||
'/__experimental/menus?per_page=100&context=edit&_locale=user', | ||
'/wp/v2/types?context=edit', | ||
"/__experimental/menu-items?context=edit&menus={$menu_id}&per_page=100&_locale=user", | ||
); | ||
|
||
$this->callback->expects( $this->once() ) | ||
->method( 'preload_paths_callback' ) | ||
->with( $expected_preload_paths ) | ||
->willReturn( array() ); | ||
|
||
$menu = new stdClass(); | ||
$menu->term_id = $menu_id; | ||
$this->callback->expects( $this->once() ) | ||
->method( 'wp_nav_menus_callback' ) | ||
->with( array() ) | ||
->willReturn( array( new WP_Term( $menu ) ) ); | ||
|
||
set_current_screen( 'gutenberg_page_gutenberg-navigation' ); | ||
gutenberg_navigation_init( 'gutenberg_page_gutenberg-navigation' ); | ||
} | ||
|
||
public function test_gutenberg_navigation_editor_preload_menus_function_returns_correct_data() { | ||
$menus_endpoint = gutenberg_navigation_get_menus_endpoint(); | ||
$preload_data = array( | ||
'/__experimental/menu-locations' => array( 'some menu locations' ), | ||
'OPTIONS' => array( | ||
array( 'some options requests' ), | ||
), | ||
$menus_endpoint => ( 'some menus' ), | ||
); | ||
|
||
$result = gutenberg_navigation_editor_preload_menus( $preload_data, 'navigation_editor' ); | ||
$this->assertArrayHasKey( '/__experimental/menu-locations', $result ); | ||
$this->assertArrayHasKey( 'OPTIONS', $result ); | ||
$this->assertArrayNotHasKey( $menus_endpoint, $result ); | ||
} | ||
} | ||
|
||
|
||
/** | ||
* This is a utility test class for creating mocks of the callback functions | ||
*/ | ||
class WP_Navigation_Page_Test_Callback { | ||
|
||
public function preload_paths_callback() {} | ||
public function wp_nav_menus_callback() {} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is needed.
Cache records are matched by the path.
If we don't use this path, we will not be able to find the cache record.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like how the path must be kept in sync between two separate places (JS and PHP), but I also don't have any better ideas to offer. 😒