diff --git a/Gruntfile.js b/Gruntfile.js index f96eda8ce5f61..577261b5afbbb 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -595,6 +595,7 @@ module.exports = function(grunt) { [ WORKING_DIR + 'wp-includes/js/wp-auth-check.js' ]: [ './src/js/_enqueues/lib/auth-check.js' ], [ WORKING_DIR + 'wp-includes/js/wp-tooltip.js' ]: [ './src/js/_enqueues/wp/wp-tooltip.js' ], [ WORKING_DIR + 'wp-includes/js/wp-backbone.js' ]: [ './src/js/_enqueues/wp/backbone.js' ], + [ WORKING_DIR + 'wp-includes/js/wp-command-palette-loader.js' ]: [ './src/js/_enqueues/wp/command-palette-loader.js' ], [ WORKING_DIR + 'wp-includes/js/wp-custom-header.js' ]: [ './src/js/_enqueues/wp/custom-header.js' ], [ WORKING_DIR + 'wp-includes/js/wp-embed-template.js' ]: [ './src/js/_enqueues/lib/embed-template.js' ], [ WORKING_DIR + 'wp-includes/js/wp-embed.js' ]: [ './src/js/_enqueues/wp/embed.js' ], @@ -1383,6 +1384,7 @@ module.exports = function(grunt) { 'src/wp-includes/js/wp-api.js': 'src/js/_enqueues/wp/api.js', 'src/wp-includes/js/wp-auth-check.js': 'src/js/_enqueues/lib/auth-check.js', 'src/wp-includes/js/wp-backbone.js': 'src/js/_enqueues/wp/backbone.js', + 'src/wp-includes/js/wp-command-palette-loader.js': 'src/js/_enqueues/wp/command-palette-loader.js', 'src/wp-includes/js/wp-custom-header.js': 'src/js/_enqueues/wp/custom-header.js', 'src/wp-includes/js/wp-embed-template.js': 'src/js/_enqueues/lib/embed-template.js', 'src/wp-includes/js/wp-embed.js': 'src/js/_enqueues/wp/embed.js', diff --git a/src/js/_enqueues/wp/command-palette-loader.js b/src/js/_enqueues/wp/command-palette-loader.js new file mode 100644 index 0000000000000..2a85befe902d4 --- /dev/null +++ b/src/js/_enqueues/wp/command-palette-loader.js @@ -0,0 +1,171 @@ +/** + * @output wp-includes/js/wp-command-palette-loader.js + */ + +/** + * Opens the Command Palette, loading its assets on first use. + * + * On screens that do not load the Command Palette up front, its scripts and + * styles are printed into an inert template by + * wp_print_command_palette_assets_template(). They are only added to the page + * when the palette is first requested, with the keyboard shortcut or the + * toolbar button, so that they do not delay every admin page load. + * + * @since 7.3.0 + */ +( function ( window, document ) { + const TEMPLATE_ID = 'wp-command-palette-assets'; + const TOOLBAR_LINK_SELECTOR = '#wp-admin-bar-command-palette > .ab-item'; + const IS_APPLE_OS = /Mac|iPod|iPhone|iPad/.test( window.navigator.platform ); + + let isLoading = false; + + /** + * Tells whether a key press is the shortcut that opens the Command Palette. + * + * @param {KeyboardEvent} event The key press. + * @return {boolean} Whether the key press is Cmd+K on Apple devices or Ctrl+K elsewhere. + */ + function isOpenPaletteShortcut( event ) { + const primaryModifier = IS_APPLE_OS ? event.metaKey : event.ctrlKey; + + return primaryModifier && ! event.altKey && ! event.shiftKey && 'k' === event.key?.toLowerCase(); + } + + /** + * Tells whether the Command Palette assets are still waiting in their template. + * + * @return {boolean} Whether the assets have not been added to the page yet. + */ + function arePaletteAssetsPending() { + return isLoading || null !== document.getElementById( TEMPLATE_ID ); + } + + /** + * Adds a script to the page and waits until it has run. + * + * A script added with the DOM runs as soon as it is inserted when it is + * inline, but only after it has loaded when it has a source. Waiting for + * each one keeps the order in which they were printed. + * + * @param {HTMLScriptElement} templateScript The script as printed in the template. + * @return {Promise} Resolves once the script has run or failed to load. + */ + function insertScript( templateScript ) { + const script = document.createElement( 'script' ); + + for ( const attribute of templateScript.attributes ) { + script.setAttribute( attribute.name, attribute.value ); + } + script.text = templateScript.text; + + if ( ! script.hasAttribute( 'src' ) ) { + document.body.appendChild( script ); + return Promise.resolve(); + } + + return new Promise( ( resolve ) => { + script.onload = resolve; + script.onerror = resolve; + document.body.appendChild( script ); + } ); + } + + /** + * Adds a stylesheet to the page and waits until it has loaded. + * + * @param {HTMLLinkElement|HTMLStyleElement} templateStyle The style as printed in the template. + * @return {Promise} Resolves once the stylesheet has loaded or failed to load. + */ + function insertStyle( templateStyle ) { + const style = document.importNode( templateStyle, true ); + + if ( 'LINK' !== style.tagName ) { + document.head.appendChild( style ); + return Promise.resolve(); + } + + return new Promise( ( resolve ) => { + style.onload = resolve; + style.onerror = resolve; + document.head.appendChild( style ); + } ); + } + + /** + * Asks the browser to start downloading a script before it is inserted. + * + * @param {string} url The script URL. + */ + function preloadScript( url ) { + const link = document.createElement( 'link' ); + + link.rel = 'preload'; + link.as = 'script'; + link.href = url; + document.head.appendChild( link ); + } + + /** + * Adds the Command Palette's scripts and styles from the template to the page. + * + * All scripts are preloaded at once and then run one after the other. + * + * @param {DocumentFragment} assets The template content. + * @return {Promise} Resolves once every script has run and every stylesheet has loaded. + */ + async function insertPaletteAssets( assets ) { + const scripts = Array.from( assets.querySelectorAll( 'script' ) ); + const styles = Array.from( assets.querySelectorAll( 'link[rel="stylesheet"], style' ) ); + + scripts + .filter( ( script ) => script.hasAttribute( 'src' ) ) + .forEach( ( script ) => preloadScript( script.getAttribute( 'src' ) ) ); + + const stylesLoaded = Promise.all( styles.map( insertStyle ) ); + + for ( const script of scripts ) { + await insertScript( script ); + } + + await stylesLoaded; + } + + /** + * Opens the Command Palette, adding its assets to the page first if needed. + */ + async function openPalette() { + if ( isLoading ) { + return; + } + + const template = document.getElementById( TEMPLATE_ID ); + + if ( template ) { + isLoading = true; + template.remove(); + await insertPaletteAssets( template.content ); + isLoading = false; + } + + window.wp.data.dispatch( 'core/commands' ).open(); + } + + document.addEventListener( 'keydown', ( event ) => { + if ( event.defaultPrevented || ! isOpenPaletteShortcut( event ) || ! arePaletteAssetsPending() ) { + return; + } + + event.preventDefault(); + openPalette(); + } ); + + document.addEventListener( 'click', ( event ) => { + if ( ! event.target.closest( TOOLBAR_LINK_SELECTOR ) ) { + return; + } + + event.preventDefault(); + openPalette(); + } ); +}( window, document ) ); diff --git a/src/wp-includes/admin-bar.php b/src/wp-includes/admin-bar.php index 3600b0d814237..e2798cee1fa99 100644 --- a/src/wp-includes/admin-bar.php +++ b/src/wp-includes/admin-bar.php @@ -1011,11 +1011,12 @@ function wp_admin_bar_edit_menu( $wp_admin_bar ) { * for opening the command palette. * * @since 7.0.0 + * @since 7.3.0 Opens the palette through the script enqueued by wp_enqueue_command_palette_loader(). * * @param WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance. */ function wp_admin_bar_command_palette_menu( WP_Admin_Bar $wp_admin_bar ): void { - if ( ! is_admin() || ! wp_script_is( 'wp-core-commands', 'enqueued' ) ) { + if ( ! is_admin() || ! wp_script_is( 'wp-command-palette-loader', 'enqueued' ) ) { return; } @@ -1064,9 +1065,8 @@ function wp_admin_bar_command_palette_menu( WP_Admin_Bar $wp_admin_bar ): void { 'title' => $title, 'href' => '#', 'meta' => array( - 'class' => 'hide-if-no-js', - 'onclick' => 'wp.data.dispatch( "core/commands" ).open(); return false;', - 'html' => wp_get_inline_script_tag( $script ), + 'class' => 'hide-if-no-js', + 'html' => wp_get_inline_script_tag( $script ), ), ) ); diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 3c1234f9cfa8e..e18a367e46138 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -632,7 +632,8 @@ add_action( 'wp_enqueue_scripts', 'wp_enqueue_classic_theme_styles' ); add_action( 'admin_enqueue_scripts', 'wp_localize_jquery_ui_datepicker', 1000 ); add_action( 'admin_enqueue_scripts', 'wp_common_block_scripts_and_styles' ); -add_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' ); +add_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_loader' ); +add_action( 'admin_print_footer_scripts', 'wp_print_command_palette_assets_template', 20 ); add_action( 'admin_head', 'wp_print_view_transitions_render_blocking_link' ); add_action( 'enqueue_block_assets', 'wp_enqueue_classic_theme_styles' ); add_action( 'enqueue_block_assets', 'wp_enqueue_registered_block_scripts_and_styles' ); diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 56acc94dbbe00..5eabb94d5c4ff 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1245,6 +1245,8 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'admin-bar', "/wp-includes/js/admin-bar$suffix.js", array( 'hoverintent-js' ), false, 1 ); + $scripts->add( 'wp-command-palette-loader', "/wp-includes/js/wp-command-palette-loader$suffix.js", array(), false, 1 ); + $scripts->add( 'wplink', "/wp-includes/js/wplink$suffix.js", array( 'common', 'jquery', 'wp-a11y', 'wp-i18n' ), false, 1 ); $scripts->set_translations( 'wplink' ); did_action( 'init' ) && $scripts->localize( @@ -2498,9 +2500,9 @@ function wp_prefetch_admin_assets(): void { * * Scripts printed in the footer are left out even though they block DOMContentLoaded, since * they do not hold back the first paint. They are also where the bulk of the admin's bytes - * are, largely the command palette's dependencies, and a prefetch of them still in flight - * when the login form is submitted competes with the admin screen's own render-blocking - * stylesheets and delays its first paint on a slow connection. + * are, and a prefetch of them still in flight when the login form is submitted competes with + * the admin screen's own render-blocking stylesheets and delays its first paint on a slow + * connection. * * These are roots rather than the full set: everything they depend on is pulled in with them * below, so the set follows the dependencies declared in wp_default_scripts() and @@ -2512,8 +2514,9 @@ function wp_prefetch_admin_assets(): void { * * Each root mirrors an enqueue elsewhere, which carries a note pointing back here: `common` * (for `jquery`) in wp-admin/admin.php, `colors` (for `wp-admin` and `buttons`) and `utils` in - * wp-admin/admin-header.php, `admin-bar` in WP_Admin_Bar::initialize(), `wp-auth-check` in - * wp_auth_check_load(), and `wp-commands` in wp_enqueue_command_palette_assets(). + * wp-admin/admin-header.php, `admin-bar` in WP_Admin_Bar::initialize(), and `wp-auth-check` + * in wp_auth_check_load(). The command palette's stylesheet is not among them: admin screens + * load it when the palette is first opened, see wp_enqueue_command_palette_loader(). */ $script_roots = array( 'jquery', @@ -2525,7 +2528,6 @@ function wp_prefetch_admin_assets(): void { 'buttons', 'admin-bar', 'wp-auth-check', - 'wp-commands', ); } @@ -3830,12 +3832,32 @@ function wp_enqueue_classic_theme_styles() { /** * Enqueues the assets required for the Command Palette. * + * Loads the whole palette up front. Admin screens use wp_enqueue_command_palette_loader() + * instead, which loads the palette when it is first opened. + * * @since 6.9.0 + * @since 7.3.0 Moved the palette's settings to wp_enqueue_command_palette_loader(). + */ +function wp_enqueue_command_palette_assets() { + wp_enqueue_script( 'wp-commands' ); + wp_enqueue_command_palette_loader(); +} + +/** + * Enqueues the Command Palette loader and the palette's settings. + * + * The loader adds the palette's scripts and styles to the page when the palette is first + * opened, with the keyboard shortcut or the toolbar button, so that they do not hold back + * every admin screen. Until then they wait in the template printed by + * wp_print_command_palette_assets_template(). A screen that already loads the commands + * package, such as the block editor, gets the rest of the palette up front instead. + * + * @since 7.3.0 * * @global array $menu * @global array $submenu */ -function wp_enqueue_command_palette_assets() { +function wp_enqueue_command_palette_loader() { global $menu, $submenu; $command_palette_settings = array( @@ -3946,10 +3968,7 @@ function wp_enqueue_command_palette_assets() { $command_palette_settings['menu_commands'] = $menu_commands; } - wp_enqueue_script( 'wp-commands' ); - // Prefetched from the login screen by wp_prefetch_admin_assets(), which needs updating if this changes. - wp_enqueue_style( 'wp-commands' ); - wp_enqueue_script( 'wp-core-commands' ); + wp_enqueue_script( 'wp-command-palette-loader' ); wp_add_inline_script( 'wp-core-commands', @@ -3958,6 +3977,56 @@ function wp_enqueue_command_palette_assets() { wp_json_encode( $command_palette_settings, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) ) ); + + if ( wp_script_is( 'wp-commands', 'enqueued' ) ) { + wp_enqueue_style( 'wp-commands' ); + wp_enqueue_script( 'wp-core-commands' ); + + /** + * Fires when the Command Palette's scripts and styles are enqueued. + * + * Scripts and styles that add commands to the palette should be enqueued on this + * action. On screens that load the palette when it is first opened, the action fires + * in the admin footer, and what is enqueued on it is loaded together with the palette. + * + * @since 7.3.0 + */ + do_action( 'enqueue_command_palette_assets' ); + } +} + +/** + * Prints the Command Palette's scripts and styles into an inert template. + * + * The template holds what the palette needs and the page has not printed yet, including the + * scripts and styles enqueued on the {@see 'enqueue_command_palette_assets'} action. The script + * enqueued by wp_enqueue_command_palette_loader() adds it to the page when the palette is + * first opened. Nothing is printed when the palette is already on the page. + * + * @since 7.3.0 + */ +function wp_print_command_palette_assets_template() { + if ( ! wp_script_is( 'wp-command-palette-loader', 'done' ) || wp_script_is( 'wp-core-commands', 'done' ) ) { + return; + } + + $scripts = wp_scripts(); + $styles = wp_styles(); + $scripts_queued_by_page = $scripts->queue; + $styles_queued_by_page = $styles->queue; + + /** This action is documented in wp-includes/script-loader.php */ + do_action( 'enqueue_command_palette_assets' ); + + $palette_scripts = array_merge( array( 'wp-core-commands' ), array_diff( $scripts->queue, $scripts_queued_by_page ) ); + $palette_styles = array_merge( array( 'wp-commands' ), array_diff( $styles->queue, $styles_queued_by_page ) ); + + ob_start(); + $styles->do_items( $palette_styles ); + $scripts->do_items( $palette_scripts ); + $assets = ob_get_clean(); + + printf( "\n", $assets ); } /** diff --git a/tests/e2e/specs/command-palette.test.js b/tests/e2e/specs/command-palette.test.js new file mode 100644 index 0000000000000..001ee496849ac --- /dev/null +++ b/tests/e2e/specs/command-palette.test.js @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Command Palette', () => { + test( 'is loaded on demand and opens with the keyboard shortcut on a classic screen', async ( { + admin, + page, + pageUtils, + } ) => { + await admin.visitAdminPage( '/options-general.php' ); + + await expect( page.locator( 'script#wp-core-commands-js' ) ).toHaveCount( 0 ); + await expect( page.locator( 'script#react-js' ) ).toHaveCount( 0 ); + + await pageUtils.pressKeys( 'primary+k' ); + + const palette = page.getByRole( 'dialog', { name: 'Command palette' } ); + await expect( palette ).toBeVisible(); + await expect( page.locator( 'script#wp-core-commands-js' ) ).toHaveCount( 1 ); + + await palette.getByRole( 'combobox' ).fill( 'Permalinks' ); + await palette + .getByRole( 'option', { name: /Settings > Permalinks/ } ) + .click(); + await expect( page ).toHaveURL( /\/wp-admin\/options-permalink\.php/ ); + } ); + + test( 'opens again after it has been loaded', async ( { + admin, + page, + pageUtils, + } ) => { + await admin.visitAdminPage( '/options-general.php' ); + + const palette = page.getByRole( 'dialog', { name: 'Command palette' } ); + + await pageUtils.pressKeys( 'primary+k' ); + await expect( palette ).toBeVisible(); + await page.keyboard.press( 'Escape' ); + await expect( palette ).toBeHidden(); + + await pageUtils.pressKeys( 'primary+k' ); + await expect( palette ).toBeVisible(); + } ); + + test( 'opens from the toolbar button on a classic screen', async ( { + admin, + page, + } ) => { + await admin.visitAdminPage( '/edit.php' ); + + await page.locator( '#wp-admin-bar-command-palette > .ab-item' ).click(); + + await expect( + page.getByRole( 'dialog', { name: 'Command palette' } ) + ).toBeVisible(); + } ); + + test( 'is loaded up front in the block editor', async ( { + admin, + page, + pageUtils, + } ) => { + await admin.createNewPost(); + + await expect( page.locator( 'script#wp-core-commands-js' ) ).toHaveCount( 1 ); + await expect( page.locator( '#wp-command-palette-assets' ) ).toHaveCount( 0 ); + + await pageUtils.pressKeys( 'primary+k' ); + + await expect( + page.getByRole( 'dialog', { name: 'Command palette' } ) + ).toBeVisible(); + } ); +} ); diff --git a/tests/phpunit/tests/dependencies/wpCommandPaletteLoader.php b/tests/phpunit/tests/dependencies/wpCommandPaletteLoader.php new file mode 100644 index 0000000000000..3f1729a00fdce --- /dev/null +++ b/tests/phpunit/tests/dependencies/wpCommandPaletteLoader.php @@ -0,0 +1,312 @@ + + */ + private $original_globals = array(); + + public static function set_up_before_class() { + parent::set_up_before_class(); + + require_once ABSPATH . WPINC . '/class-wp-admin-bar.php'; + } + + /** + * Gives each test fresh script and style registries and an admin screen. + */ + public function set_up(): void { + parent::set_up(); + + foreach ( array( 'wp_scripts', 'wp_styles', 'menu', 'submenu' ) as $name ) { + $this->original_globals[ $name ] = $GLOBALS[ $name ] ?? null; + unset( $GLOBALS[ $name ] ); + } + + $GLOBALS['menu'] = array(); + $GLOBALS['submenu'] = array(); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + set_current_screen( 'options-general' ); + } + + /** + * Restores the globals the test replaced. + */ + public function tear_down(): void { + foreach ( $this->original_globals as $name => $value ) { + if ( null === $value ) { + unset( $GLOBALS[ $name ] ); + } else { + $GLOBALS[ $name ] = $value; + } + } + + set_current_screen( 'front' ); + + parent::tear_down(); + } + + /** + * Tests that the loader is hooked to admin screens in place of the palette's assets. + * + * @ticket 66642 + */ + public function test_loader_is_hooked_to_admin_enqueue_scripts(): void { + $this->assertSame( 10, has_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_loader' ) ); + $this->assertFalse( has_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' ) ); + $this->assertSame( 20, has_action( 'admin_print_footer_scripts', 'wp_print_command_palette_assets_template' ) ); + } + + /** + * Tests that a screen without the commands package only gets the loader, not the palette's + * scripts and styles. + * + * @ticket 66642 + */ + public function test_loader_does_not_enqueue_palette_assets_on_classic_screen(): void { + wp_enqueue_command_palette_loader(); + + $this->assertTrue( wp_script_is( 'wp-command-palette-loader', 'enqueued' ), 'Expected the loader to be enqueued.' ); + $this->assertFalse( wp_script_is( 'wp-core-commands', 'enqueued' ), 'Expected wp-core-commands not to be enqueued.' ); + $this->assertFalse( wp_script_is( 'wp-commands', 'enqueued' ), 'Expected wp-commands not to be enqueued.' ); + $this->assertFalse( wp_style_is( 'wp-commands', 'enqueued' ), 'Expected the wp-commands style not to be enqueued.' ); + $this->assertFalse( wp_script_is( 'react', 'enqueued' ), 'Expected react not to be enqueued.' ); + } + + /** + * Tests that the palette's settings are attached to wp-core-commands even when it is loaded + * on demand, so it still initializes with the menu commands. + * + * @ticket 66642 + */ + public function test_loader_adds_initialization_with_menu_commands(): void { + $GLOBALS['menu'] = array( + array( 'Settings', 'manage_options', 'options-general.php' ), + ); + + wp_enqueue_command_palette_loader(); + + $after = implode( '', wp_scripts()->get_data( 'wp-core-commands', 'after' ) ); + + $this->assertStringContainsString( 'wp.coreCommands.initializeCommandPalette(', $after ); + $this->assertStringContainsString( '"url":"options-general.php"', $after ); + } + + /** + * Tests that a screen which already loads the commands package, such as the block editor, + * keeps loading the whole palette up front. + * + * @ticket 66642 + */ + public function test_loader_enqueues_palette_assets_when_screen_loads_commands_package(): void { + wp_register_script( 'test-editor', '/test-editor.js', array( 'wp-commands' ) ); + wp_enqueue_script( 'test-editor' ); + + wp_enqueue_command_palette_loader(); + + $this->assertTrue( wp_script_is( 'wp-core-commands', 'enqueued' ), 'Expected wp-core-commands to be enqueued.' ); + $this->assertTrue( wp_style_is( 'wp-commands', 'enqueued' ), 'Expected the wp-commands style to be enqueued.' ); + } + + /** + * Tests that enqueuing the palette's assets directly still loads all of them up front. + * + * @ticket 66642 + */ + public function test_enqueue_assets_loads_palette_up_front(): void { + wp_enqueue_command_palette_assets(); + + $this->assertTrue( wp_script_is( 'wp-command-palette-loader', 'enqueued' ), 'Expected the loader to be enqueued.' ); + $this->assertTrue( wp_script_is( 'wp-commands', 'enqueued' ), 'Expected wp-commands to be enqueued.' ); + $this->assertTrue( wp_script_is( 'wp-core-commands', 'enqueued' ), 'Expected wp-core-commands to be enqueued.' ); + $this->assertTrue( wp_style_is( 'wp-commands', 'enqueued' ), 'Expected the wp-commands style to be enqueued.' ); + $this->assertCount( + 1, + wp_scripts()->get_data( 'wp-core-commands', 'after' ), + 'Expected the palette to be initialized once.' + ); + } + + /** + * Tests that the template holds the palette's scripts, their inline scripts and its styles, + * leaving out what the page already printed. + * + * @ticket 66642 + */ + public function test_template_holds_palette_assets_not_yet_printed(): void { + wp_enqueue_command_palette_loader(); + wp_scripts()->done[] = 'wp-command-palette-loader'; + wp_scripts()->done[] = 'wp-i18n'; + + $output = get_echo( 'wp_print_command_palette_assets_template' ); + + $processor = new WP_HTML_Tag_Processor( $output ); + $this->assertTrue( $processor->next_tag( 'TEMPLATE' ), 'Expected a template element.' ); + $this->assertSame( 'wp-command-palette-assets', $processor->get_attribute( 'id' ) ); + + $this->assertMatchesRegularExpression( '#]+src="[^"]*/dist/core-commands(\.min)?\.js#', $output ); + $this->assertMatchesRegularExpression( '#]+src="[^"]*/dist/commands(\.min)?\.js#', $output ); + $this->assertMatchesRegularExpression( '#]+src="[^"]*/vendor/react(\.min)?\.js#', $output ); + $this->assertMatchesRegularExpression( '#]+href=[\'"][^\'"]*/css/dist/commands/style(\.min)?\.css#', $output ); + $this->assertMatchesRegularExpression( '#]+href=[\'"][^\'"]*/css/dist/components/style(\.min)?\.css#', $output ); + $this->assertStringContainsString( 'wp.coreCommands.initializeCommandPalette(', $output ); + $this->assertDoesNotMatchRegularExpression( '#/dist/i18n(\.min)?\.js#', $output, 'Expected scripts already on the page not to be printed again.' ); + $this->assertStringEndsWith( "\n", $output ); + } + + /** + * Tests that the palette's action fires when the palette is loaded up front, and not when it + * is loaded on demand. + * + * @ticket 66642 + * + * @dataProvider data_enqueue_command_palette_assets_action + * + * @param bool $screen_loads_commands_package Whether the screen enqueues a script depending on wp-commands. + * @param int $expected_count Expected number of times the action fires. + */ + public function test_loader_fires_action_only_when_palette_loads_up_front( bool $screen_loads_commands_package, int $expected_count ): void { + $action = new MockAction(); + add_action( 'enqueue_command_palette_assets', array( $action, 'action' ) ); + + if ( $screen_loads_commands_package ) { + wp_register_script( 'test-editor', '/test-editor.js', array( 'wp-commands' ) ); + wp_enqueue_script( 'test-editor' ); + } + + wp_enqueue_command_palette_loader(); + + $this->assertSame( $expected_count, $action->get_call_count() ); + } + + /** + * Data provider for test_loader_fires_action_only_when_palette_loads_up_front(). + * + * @return array + */ + public function data_enqueue_command_palette_assets_action(): array { + return array( + 'palette loaded on demand' => array( false, 0 ), + 'palette loaded up front' => array( true, 1 ), + ); + } + + /** + * Tests that enqueuing the palette's assets directly fires the palette's action. + * + * @ticket 66642 + */ + public function test_enqueue_assets_fires_action(): void { + $action = new MockAction(); + add_action( 'enqueue_command_palette_assets', array( $action, 'action' ) ); + + wp_enqueue_command_palette_assets(); + + $this->assertSame( 1, $action->get_call_count() ); + } + + /** + * Tests that scripts and styles enqueued on the palette's action go into the template, + * with their localized data, while what the page itself enqueued stays out of it. + * + * @ticket 66642 + */ + public function test_template_holds_assets_enqueued_on_action(): void { + wp_register_script( 'test-page-script', '/test-page-script.js' ); + wp_enqueue_script( 'test-page-script' ); + + add_action( + 'enqueue_command_palette_assets', + static function () { + wp_enqueue_script( 'test-palette-commands', '/test-palette-commands.js', array( 'wp-commands' ) ); + wp_localize_script( 'test-palette-commands', 'testPaletteCommands', array( 'label' => 'Test command' ) ); + wp_enqueue_style( 'test-palette-commands', '/test-palette-commands.css' ); + } + ); + + wp_enqueue_command_palette_loader(); + wp_scripts()->done[] = 'wp-command-palette-loader'; + + $output = get_echo( 'wp_print_command_palette_assets_template' ); + + $this->assertStringContainsString( '/test-palette-commands.js', $output ); + $this->assertStringContainsString( 'var testPaletteCommands = {"label":"Test command"};', $output ); + $this->assertStringContainsString( '/test-palette-commands.css', $output ); + $this->assertStringNotContainsString( '/test-page-script.js', $output, 'Expected the page\'s own scripts not to be printed into the template.' ); + } + + /** + * Tests that no template is printed when the palette is already on the page, or the loader is not. + * + * @ticket 66642 + * + * @dataProvider data_template_is_not_printed + * + * @param string[] $done_handles Script handles already printed. + */ + public function test_template_is_not_printed( array $done_handles ): void { + wp_enqueue_command_palette_loader(); + wp_scripts()->done = array_merge( wp_scripts()->done, $done_handles ); + + $this->assertSame( '', get_echo( 'wp_print_command_palette_assets_template' ) ); + } + + /** + * Data provider for test_template_is_not_printed(). + * + * @return array + */ + public function data_template_is_not_printed(): array { + return array( + 'loader not printed' => array( array() ), + 'palette already printed' => array( array( 'wp-command-palette-loader', 'wp-core-commands' ) ), + ); + } + + /** + * Tests that the toolbar button is added when the loader is enqueued, and opens the palette + * through it rather than through an inline handler that needs wp-data on the page. + * + * @ticket 66642 + */ + public function test_admin_bar_button_relies_on_loader(): void { + wp_enqueue_command_palette_loader(); + + $admin_bar = new WP_Admin_Bar(); + wp_admin_bar_command_palette_menu( $admin_bar ); + $node = $admin_bar->get_node( 'command-palette' ); + + $this->assertNotNull( $node, 'Expected the command palette button.' ); + $this->assertArrayNotHasKey( 'onclick', $node->meta ); + } + + /** + * Tests that the toolbar button is left out when the palette is not available. + * + * @ticket 66642 + */ + public function test_admin_bar_button_requires_loader(): void { + $admin_bar = new WP_Admin_Bar(); + wp_admin_bar_command_palette_menu( $admin_bar ); + + $this->assertNull( $admin_bar->get_node( 'command-palette' ) ); + } +} diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 1daabba5e8435..00fe78159cf6d 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -104,7 +104,9 @@ public function test_login_prefetches_render_blocking_admin_assets(): void { $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); $this->assertPrefetched( $links, 'style', '#/wp-includes/css/buttons(\.min)?\.css#' ); $this->assertPrefetched( $links, 'style', '#/wp-includes/css/admin-bar(\.min)?\.css#' ); - $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/commands/style(\.min)?\.css#' ); + + // The command palette is loaded when it is first opened, so its stylesheet is not prefetched. + $this->assertNotPrefetched( $links, '#/wp-includes/css/dist/commands/style(\.min)?\.css#' ); // Footer scripts are not prefetched. $this->assertNotPrefetched( $links, '#/wp-admin/js/common(\.min)?\.js#' );