From 7a7d8b38ae5ef0f4b8aee1111d2ac54e9073ef06 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 9 Nov 2026 21:11:43 +0100 Subject: [PATCH 1/2] Script Loader: Load the Command Palette on demand. Every admin screen enqueued `wp-commands` and `wp-core-commands`, which pull in about 40 block editor scripts and the `wp-components` stylesheet before `DOMContentLoaded`, although the palette is only needed once it is opened. Admin screens now enqueue a small loader instead. The palette's scripts and styles are printed into an inert template in the footer and added to the page on the first Ctrl/Cmd+K or click on the toolbar button. Screens that already load the commands package, such as the block editor, keep loading the palette up front, as does `wp_enqueue_command_palette_assets()`. On `options-general.php` this drops the scripts on the page from 80 to 39 and the median DOMContentLoaded with a warm cache from 127 ms to 57 ms. See #66642. --- Gruntfile.js | 2 + src/js/_enqueues/wp/command-palette-loader.js | 171 +++++++++++++ src/wp-includes/admin-bar.php | 8 +- src/wp-includes/default-filters.php | 3 +- src/wp-includes/script-loader.php | 70 +++++- tests/e2e/specs/command-palette.test.js | 77 ++++++ .../dependencies/wpCommandPaletteLoader.php | 231 ++++++++++++++++++ .../dependencies/wpPrefetchAdminAssets.php | 4 +- 8 files changed, 549 insertions(+), 17 deletions(-) create mode 100644 src/js/_enqueues/wp/command-palette-loader.js create mode 100644 tests/e2e/specs/command-palette.test.js create mode 100644 tests/phpunit/tests/dependencies/wpCommandPaletteLoader.php 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..247f4f2a2f7f2 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,34 @@ 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_command_palette_loader(); + wp_enqueue_script( 'wp-commands' ); + wp_enqueue_style( 'wp-commands' ); + wp_enqueue_script( 'wp-core-commands' ); +} + +/** + * 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 +3970,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 +3979,33 @@ 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' ); + } +} + +/** + * 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. 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; + } + + ob_start(); + wp_styles()->do_items( array( 'wp-commands' ) ); + wp_scripts()->do_items( array( 'wp-core-commands' ) ); + $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..4d22e8b5b0a83 --- /dev/null +++ b/tests/phpunit/tests/dependencies/wpCommandPaletteLoader.php @@ -0,0 +1,231 @@ + + */ + 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 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#' ); From 8b749f6e87d4157709f896eb2226a09a4222bd40 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 10 Nov 2026 16:48:35 +0100 Subject: [PATCH 2/2] Script Loader: Add an action for Command Palette assets. Plugins that add commands to the palette, such as User Switching, enqueue their script only when `wp_script_is( 'wp-commands', 'enqueued' )` is true. Since the palette is now loaded on demand on classic screens, that check is false there and their commands were missing. The new `enqueue_command_palette_assets` action fires wherever the palette's assets are enqueued: right away on screens that load the palette up front, and in the admin footer on screens that load it on demand, where whatever is enqueued on it is printed into the palette's template and loaded together with the palette. `wp_enqueue_command_palette_assets()` now enqueues `wp-commands` and lets `wp_enqueue_command_palette_loader()` take the up-front path, so the action fires for its direct callers too. See #66642. --- src/wp-includes/script-loader.php | 33 ++++++-- .../dependencies/wpCommandPaletteLoader.php | 81 +++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 247f4f2a2f7f2..5eabb94d5c4ff 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -3839,10 +3839,8 @@ function wp_enqueue_classic_theme_styles() { * @since 7.3.0 Moved the palette's settings to wp_enqueue_command_palette_loader(). */ function wp_enqueue_command_palette_assets() { - wp_enqueue_command_palette_loader(); wp_enqueue_script( 'wp-commands' ); - wp_enqueue_style( 'wp-commands' ); - wp_enqueue_script( 'wp-core-commands' ); + wp_enqueue_command_palette_loader(); } /** @@ -3983,13 +3981,25 @@ function wp_enqueue_command_palette_loader() { 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. The script + * 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. * @@ -4000,9 +4010,20 @@ function wp_print_command_palette_assets_template() { 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(); - wp_styles()->do_items( array( 'wp-commands' ) ); - wp_scripts()->do_items( array( 'wp-core-commands' ) ); + $styles->do_items( $palette_styles ); + $scripts->do_items( $palette_scripts ); $assets = ob_get_clean(); printf( "\n", $assets ); diff --git a/tests/phpunit/tests/dependencies/wpCommandPaletteLoader.php b/tests/phpunit/tests/dependencies/wpCommandPaletteLoader.php index 4d22e8b5b0a83..3f1729a00fdce 100644 --- a/tests/phpunit/tests/dependencies/wpCommandPaletteLoader.php +++ b/tests/phpunit/tests/dependencies/wpCommandPaletteLoader.php @@ -172,6 +172,87 @@ public function test_template_holds_palette_assets_not_yet_printed(): void { $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. *