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

Script Modules API #5818

Closed
wants to merge 36 commits into from
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8347e8b
Modules API: Initial version
luisherranz Dec 22, 2023
27ba624
Move actions to default-filters.php
luisherranz Dec 23, 2023
156eb58
Move module functions to its own file
luisherranz Dec 23, 2023
bbae17c
Use wp_print_inline_script_tag to print import map
luisherranz Dec 23, 2023
76e8726
Divide tests into two files and improve covers
luisherranz Dec 23, 2023
f0fa726
Switch to ReflectionProperty to setStaticPropertyValue
luisherranz Dec 23, 2023
f13a068
Refactor the tests set_up
luisherranz Dec 23, 2023
ef978d8
Test that static dependencies of dynamic dependencies are not preloaded
luisherranz Jan 3, 2024
212cdac
Add since to main class DocBlock
luisherranz Jan 3, 2024
e644da8
Change assertEquals with custom ones
luisherranz Jan 3, 2024
1aa45a6
Avoid static methods and rely on instances
luisherranz Jan 4, 2024
eaaa563
Introduce wrapper functions for printing methods
luisherranz Jan 4, 2024
e30c8e9
Remove space between @since and @var in class properties
luisherranz Jan 4, 2024
a13bc87
Test that version is propagated correctly
luisherranz Jan 4, 2024
67f3900
Cap parameter descriptions to 120 chars
luisherranz Jan 4, 2024
66987f0
Turn $enqueued_before_registered into a map
luisherranz Jan 4, 2024
fda294d
Remove SCRIPT_DEBUG support
luisherranz Jan 4, 2024
c94568d
Rename enqueued to enqueue
luisherranz Jan 8, 2024
2eaf6ea
Add failing test for multiple prints of enqueued modules
luisherranz Jan 8, 2024
4dcfc88
Fix test
luisherranz Jan 8, 2024
a4f9812
Add failing test for multiple prints of preloaded modules
luisherranz Jan 8, 2024
89a82fc
Fix the test
luisherranz Jan 8, 2024
9f08176
Print enqueued and preloaded modules in the head and footer
luisherranz Jan 8, 2024
4b815bc
Move the hooks to the class
luisherranz Jan 9, 2024
bc8c633
Rename `type` key to `import`
luisherranz Jan 9, 2024
b3554d6
Add _doing_it_wrongs
luisherranz Jan 9, 2024
35a9402
Merge remote-tracking branch 'origin/add/modules-api' into add/module…
luisherranz Jan 9, 2024
df73485
Improve @param type of $deps
luisherranz Jan 9, 2024
8fd7501
Indicate how the new print functions work
luisherranz Jan 9, 2024
506784a
Reorder methods
luisherranz Jan 9, 2024
168ae91
Test an empty import map
luisherranz Jan 9, 2024
1df672d
Replace get_version_query_string with get_versioned_src
luisherranz Jan 9, 2024
0cb7f1b
Use `esc_url` instead of `esc_attr` for link's href attribute
luisherranz Jan 9, 2024
834693d
Add suffixes to the ids and an id to the import map
luisherranz Jan 9, 2024
b2ff803
Rename class to WP_Script_Modules
luisherranz Jan 10, 2024
adb5913
Allow wp_enqueue_module to also register modules
luisherranz Jan 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
246 changes: 246 additions & 0 deletions src/wp-includes/class-wp-modules.php
@@ -0,0 +1,246 @@
<?php
/**
* Modules API: WP_Modules class.
*
* Native support for ES Modules and Import Maps.
*
* @package WordPress
* @subpackage Modules
*/

/**
* Core class used to register modules.
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
*
* @since 6.5.0
*/
class WP_Modules {
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
/**
* Holds the registered modules, keyed by module identifier.
*
* @since 6.5.0
* @var array
*/
private $registered = array();

/**
* Holds the module identifiers that were enqueued before registered.
*
* @since 6.5.0
* @var array
*/
private $enqueued_before_registered = array();

/**
* Registers the module if no module with that module identifier has already
* been registered.
*
* @since 6.5.0
*
* @param string $module_identifier The identifier of the module. Should be unique. It will be used in the final import map.
* @param string $src Full URL of the module, or path of the script relative to the WordPress root directory.
* @param array $dependencies Optional. An array of module identifiers of the dependencies of this module. The dependencies can be strings or arrays. If they are arrays, they need an `id` key with the module identifier, and can contain a `type` key with either `static` or `dynamic`. By default, dependencies that don't contain a type are considered static.
* @param string|false|null $version Optional. String specifying module version number. Defaults to false. It is added to the URL as a query string for cache busting purposes. If SCRIPT_DEBUG is true, the version is the current timestamp. If $version is set to false, the version number is the currently installed WordPress version. If $version is set to null, no version is added.
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
*/
public function register( $module_identifier, $src, $dependencies = array(), $version = false ) {
if ( ! isset( $this->registered[ $module_identifier ] ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

Registers the module if no module with that module identifier has already been registered.

How can plugins override the registered module? It would be great to add a unit test that covers that use case, too.

Copy link
Member Author

Choose a reason for hiding this comment

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

They currently can't. Do you think it's necessary for the initial version?

If so, what would be the ideal API to override a module registration?

I'm not familiar with the use case, but to me, the fact that you need to deregister the script to override it it's not very straightforward.

if ( wp_script_is( 'foo', 'registered' ) ) {
  // Deregister so we can override.
  wp_deregister_script( 'foo' );
}
wp_register_script( 'foo', ... );

Or alternatively:

$registered = wp_register_script( 'foo', ... );
if ( ! $registered ) {
  wp_deregister_script( 'foo' ); // Deregister so we can override.
  wp_register_script( 'foo', ... );
}

I'd rather expose a more explicit API, but curious to hear other's opinions 🙂

Copy link
Member

Choose a reason for hiding this comment

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

+1 for only allowing to "override" by deregistering the module explicitly first. That is also the way required for the existing core APIs for scripts and stylesheets.

I think an explicit API makes it less error-prone, allowing to simply override could lead to accidental overrides too easily, while by requiring deregistration first it'll surely be intentional.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've been thinking about this during the last few days, and I don't think adding a deregister method is a good idea at this moment.

First, the use cases for that API are indirect:

  • Override a module.
  • Prevent the enqueuing of a module.

There is no clear direct use case for deregistering a module.

But the main reason is that there can be multiple sources for the same module identifier:

<script type="importmap">
  {
    "imports": {
      "foo": "/foo.js"
    },
    "scopes": {
      "/bar.js": {
        "foo": "/my-own-foo.js"
      }
    }
  }
</script>

That's a very powerful feature that we should study how to leverage in the future.

If, at some point, we allow wp_register_module to register different modules for the same module identifier, it's not clear which one wp_deregister_module should deregister.

At least, I'd rather merge this initial version without it, and wait to see what other options we have to solve the use cases that are presented when we start integrating it.

Some examples of how we could leverage `scopes` that come to my mind.
  • Support different versions through semantic versioning

    wp_register_script( 'foo', '/foo-v1.js', array(), '1.2.3' );
    wp_register_script( 'foo', '/foo-v2.js', array(), '2.4.5' );
    
    wp_register_script( 'bar', '/bar.js', array( 'foo' ) ); // Latest version.
    wp_register_script(
      'baz',
      '/baz.js',
      array( array( 'id' => 'foo', 'version' => '^1.0.0' ) )
    );
    <script type="importmap">
      {
        "imports": {
          "foo": "/foo-v2.js"
        },
        "scopes": {
          "/baz.js": {
            "foo": "/foo-v1.js"
          }
        }
      }
    </script>
  • Compat adapters for blocks using old versions

    // old block
    {
      "apiVersion": 3
      // ...
    }
    // new block
    {
      "apiVersion": 4
      // ...
    }
    <script type="importmap">
      {
        "imports": {
          "@wordpress/block-editor": "/wp-includes/js/block-editor.js"
        },
        "scopes": {
          "/wp-content/plugins/old-block/edit.js": {
            "@wordpress/block-editor": "/wp-includes/js/block-editor-v3-compat.js"
          }
        }
      }
    </script>
  • Translation APIs

    // some-block/edit.js
    import { __ } from "@wordpress/i18n";
    <script type="importmap">
      {
        "imports": {
          "@wordpress/i18n": "/wp-includes/js/i18n.js"
        },
        "scopes": {
          "/wp-content/plugins/some-block/edit.js": {
            "@wordpress/i18n": "/wp-includes/js/i18n.js?load=some-block"
          }
        }
      }
    </script>

$deps = array();
foreach ( $dependencies as $dependency ) {
if ( isset( $dependency['id'] ) ) {
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
$deps[] = array(
'id' => $dependency['id'],
'type' => isset( $dependency['type'] ) && 'dynamic' === $dependency['type'] ? 'dynamic' : 'static',
);
} elseif ( is_string( $dependency ) ) {
$deps[] = array(
'id' => $dependency,
'type' => 'static',
);
}
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
}

$this->registered[ $module_identifier ] = array(
'src' => $src,
'version' => $version,
'enqueued' => in_array( $module_identifier, $this->enqueued_before_registered, true ),
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
'dependencies' => $deps,
);
}
}

/**
* Marks the module to be enqueued in the page.
*
* @since 6.5.0
*
* @param string $module_identifier The identifier of the module.
*/
public function enqueue( $module_identifier ) {
if ( isset( $this->registered[ $module_identifier ] ) ) {
$this->registered[ $module_identifier ]['enqueued'] = true;
} elseif ( ! in_array( $module_identifier, $this->enqueued_before_registered, true ) ) {
$this->enqueued_before_registered[] = $module_identifier;
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
* Unmarks the module so it is no longer enqueued in the page.
*
* @since 6.5.0
*
* @param string $module_identifier The identifier of the module.
*/
public function dequeue( $module_identifier ) {
if ( isset( $this->registered[ $module_identifier ] ) ) {
$this->registered[ $module_identifier ]['enqueued'] = false;
}
$key = array_search( $module_identifier, $this->enqueued_before_registered, true );
if ( false !== $key ) {
array_splice( $this->enqueued_before_registered, $key, 1 );
}
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Returns the import map array.
*
* @since 6.5.0
*
* @return array Array with an `imports` key mapping to an array of module identifiers and their respective URLs, including the version query.
*/
public function get_import_map() {
$imports = array();
foreach ( $this->get_dependencies( array_keys( $this->get_enqueued() ) ) as $module_identifier => $module ) {
$imports[ $module_identifier ] = $module['src'] . $this->get_version_query_string( $module['version'] );
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
}
return array( 'imports' => $imports );
}

/**
* Prints the import map using a script tag with a type="importmap" attribute.
*
* @since 6.5.0
*/
public function print_import_map() {
$import_map = $this->get_import_map();
if ( ! empty( $import_map['imports'] ) ) {
wp_print_inline_script_tag(
wp_json_encode( $import_map, JSON_HEX_TAG | JSON_HEX_AMP ),
array(
'type' => 'importmap',
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
)
);
}
}

/**
* Prints all the enqueued modules using script tags with type="module"
* attributes.
*
* @since 6.5.0
*/
public function print_enqueued_modules() {
foreach ( $this->get_enqueued() as $module_identifier => $module ) {
wp_print_script_tag(
array(
'type' => 'module',
'src' => $module['src'] . $this->get_version_query_string( $module['version'] ),
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
'id' => $module_identifier,
)
);
}
}

/**
* Prints the the static dependencies of the enqueued modules using link tags
* with rel="modulepreload" attributes.
*
* If a module has already been enqueued, it will not be preloaded.
*
* @since 6.5.0
*/
public function print_module_preloads() {
foreach ( $this->get_dependencies( array_keys( $this->get_enqueued() ), array( 'static' ) ) as $module_identifier => $module ) {
if ( true !== $module['enqueued'] ) {
echo sprintf(
'<link rel="modulepreload" href="%s" id="%s">',
esc_attr( $module['src'] . $this->get_version_query_string( $module['version'] ) ),
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
esc_attr( $module_identifier )
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
);
}
}
}

/**
* Gets the version of a module.
*
* If SCRIPT_DEBUG is true, the version is the current timestamp. If $version
* is set to false, the version number is the currently installed WordPress
* version. If $version is set to null, no version is added. Otherwise, the
* string passed in $version is used.
*
* @since 6.5.0
*
* @param string|false|null $version The version of the module.
* @return string A string with the version, prepended by `?ver=`, or an empty string if there is no version.
*/
private function get_version_query_string( $version ) {
if ( defined( 'SCRIPT_DEBUG ' ) && SCRIPT_DEBUG ) {
return '?ver=' . time();
luisherranz marked this conversation as resolved.
Show resolved Hide resolved
} elseif ( false === $version ) {
return '?ver=' . get_bloginfo( 'version' );
} elseif ( null !== $version ) {
return '?ver=' . $version;
}
return '';
}

/**
* Retrieves an array of enqueued modules.
*
* @since 6.5.0
*
* @return array Enqueued modules, keyed by module identifier.
*/
private function get_enqueued() {
$enqueued = array();
foreach ( $this->registered as $module_identifier => $module ) {
if ( true === $module['enqueued'] ) {
$enqueued[ $module_identifier ] = $module;
}
}
return $enqueued;
}

/**
* Retrieves all the dependencies for the given module identifiers, filtered
* by types.
*
* It will consolidate an array containing a set of unique dependencies based
* on the requested types: 'static', 'dynamic', or both. This method is
* recursive and also retrieves dependencies of the dependencies.
*
* @since 6.5.0
*
* @param array $module_identifiers The identifiers of the modules for which to gather dependencies.
* @param array $types Optional. Types of dependencies to retrieve: 'static', 'dynamic', or both. Default is both.
* @return array List of dependencies, keyed by module identifier.
*/
private function get_dependencies( $module_identifiers, $types = array( 'static', 'dynamic' ) ) {
return array_reduce(
$module_identifiers,
function ( $dependency_modules, $module_identifier ) use ( $types ) {
$dependencies = array();
foreach ( $this->registered[ $module_identifier ]['dependencies'] as $dependency ) {
if (
in_array( $dependency['type'], $types, true ) &&
isset( $this->registered[ $dependency['id'] ] ) &&
! isset( $dependency_modules[ $dependency['id'] ] )
) {
$dependencies[ $dependency['id'] ] = $this->registered[ $dependency['id'] ];
}
}
return array_merge( $dependency_modules, $dependencies, $this->get_dependencies( array_keys( $dependencies ), $types ) );
},
array()
);
}
}
5 changes: 5 additions & 0 deletions src/wp-includes/default-filters.php
Expand Up @@ -748,4 +748,9 @@
// Font management.
add_action( 'wp_head', 'wp_print_font_faces', 50 );

// Modules.
add_action( 'wp_head', 'wp_print_import_map' );
add_action( 'wp_head', 'wp_print_enqueued_modules' );
add_action( 'wp_head', 'wp_print_module_preloads' );
Copy link
Member Author

Choose a reason for hiding this comment

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

Note: we need to move these prints to the footer on classic themes (related Gutenberg PR).

Copy link
Member Author

Choose a reason for hiding this comment

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

The solution used in Gutenberg was just temporary because there's no need to print all the modules (or preloads) at once. So taking into account that the order in which modules are printed is not important, and that the sooner they are on the page the better because the browser will start downloading them sooner:

  • I've modified the print functions which now keep track of the printed modules and preloads and therefore they can be called multiple times.
  • I've added hooks to print both the enqueued and preloaded modules at both the wp_head and wp_footer. They will print the modules that are already available in the wp_head (like modules of blocks in block themes, or modules from classic themes or plugins) and they will print again the modules that were not available in the wp_head but are available in the wp_footer (like modules of blocks in classic themes).
  • I've moved the import map to the footer to make sure that it includes all the dependencies.

This is the explanation included in the code:

/**
 * Adds the hooks to print the import map, enqueued modules and module
 * preloads.
 *
 * It adds the actions to print the enqueued modules and module preloads to
 * both `wp_head` and `wp_footer` because in classic themes, the modules
 * used by the theme and plugins will likely be able to be printed in the
 * `head`, but the ones used by the blocks will need to be enqueued in the
 * `footer`.
 *
 * As all modules are deferred and dependencies are handled by the browser,
 * the order of the modules is not important, but it's still better to print
 * the ones that are available when the `wp_head` is rendered, so the browser
 * starts downloading those as soon as possible.
 *
 * The import map is also printed in the footer to be able to include the
 * dependencies of all the modules, including the ones printed in the footer.
 *

Copy link
Member Author

Choose a reason for hiding this comment

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

@c4rl0sbr4v0 discovered that we can't enqueue or preload any module before we know all the dependencies because the import map needs to be added before any module:

To be honest, this is quite a surprise, especially the preload part. If someone knows better, please tell so.

I've already opened a Trac ticket to fix it: https://core.trac.wordpress.org/ticket/60240


unset( $filter, $action );
95 changes: 95 additions & 0 deletions src/wp-includes/modules.php
@@ -0,0 +1,95 @@
<?php
/**
* Modules API: Module functions
*
* @since 6.5.0
*
* @package WordPress
* @subpackage Modules
*/

/**
* Retrieves the main WP_Modules instance.
*
* This function provides access to the WP_Modules instance, creating one if it
* doesn't exist yet.
*
* @since 6.5.0
*
* @return WP_Modules The main WP_Modules instance.
*/
function wp_modules() {
static $instance = null;
if ( is_null( $instance ) ) {
$instance = new WP_Modules();
}
return $instance;
}

/**
* Registers the module if no module with that module identifier has already
* been registered.
*
* @since 6.5.0
*
* @param string $module_identifier The identifier of the module. Should be unique. It will be used in the final import map.
* @param string $src Full URL of the module, or path of the script relative to the WordPress root directory.
* @param array $dependencies Optional. An array of module identifiers of the dependencies of this module. The dependencies can be strings or arrays. If they are arrays, they need an `id` key with the module identifier, and can contain a `type` key with either `static` or `dynamic`. By default, dependencies that don't contain a type are considered static.
* @param string|false|null $version Optional. String specifying module version number. Defaults to false. It is added to the URL as a query string for cache busting purposes. If SCRIPT_DEBUG is true, the version is the current timestamp. If $version is set to false, the version number is the currently installed WordPress version. If $version is set to null, no version is added.
*/
function wp_register_module( $module_identifier, $src, $dependencies = array(), $version = false ) {
wp_modules()->register( $module_identifier, $src, $dependencies, $version );
}

/**
* Marks the module to be enqueued in the page.
*
* @since 6.5.0
*
* @param string $module_identifier The identifier of the module.
*/
function wp_enqueue_module( $module_identifier ) {
wp_modules()->enqueue( $module_identifier );
}

/**
* Unmarks the module so it is no longer enqueued in the page.
*
* @since 6.5.0
*
* @param string $module_identifier The identifier of the module.
*/
function wp_dequeue_module( $module_identifier ) {
wp_modules()->dequeue( $module_identifier );
}

/**
* Prints the import map using a script tag with a type="importmap" attribute.
*
* @since 6.5.0
*/
function wp_print_import_map() {
wp_modules()->print_import_map();
}

/**
* Prints all the enqueued modules using script tags with type="module"
* attributes.
*
* @since 6.5.0
*/
function wp_print_enqueued_modules() {
wp_modules()->print_enqueued_modules();
}

/**
* Prints the the static dependencies of the enqueued modules using link tags
* with rel="modulepreload" attributes.
*
* If a module has already been enqueued, it will not be preloaded.
*
* @since 6.5.0
*/
function wp_print_module_preloads() {
wp_modules()->print_module_preloads();
}
2 changes: 2 additions & 0 deletions src/wp-settings.php
Expand Up @@ -365,6 +365,8 @@
require ABSPATH . WPINC . '/fonts/class-wp-font-face-resolver.php';
require ABSPATH . WPINC . '/fonts/class-wp-font-face.php';
require ABSPATH . WPINC . '/fonts.php';
require ABSPATH . WPINC . '/class-wp-modules.php';
require ABSPATH . WPINC . '/modules.php';

$GLOBALS['wp_embed'] = new WP_Embed();

Expand Down