Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion src/wp-includes/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,94 @@ function register_rest_route( $route_namespace, $route, $args = array(), $overri
return true;
}

/**
* Registers a REST API namespace for lazy loading.
*
* This function registers a namespace that will only load its routes when that namespace is actually requested,
* improving performance by avoiding the loading of unused REST endpoints.
*
* Note: Do not use before the {@see 'rest_api_init'} hook.
*
* @param string $route_namespace Namespace to register for lazy loading.
* Should be unique to your package/plugin and
* include a version (e.g., 'my-plugin/v1').
*
* @return bool True on success, false on failure.
*
* @since X.X.0
*
* @example
* // Register a lazy-loaded namespace for a plugin
* add_action( 'rest_api_init', function() {
* register_rest_namespace( 'my-plugin/v1' );
* } );
*
* // Then register the actual routes when the namespace loads
* add_action( 'rest_lazy_load_namespace_my-plugin/v1', function() {
* register_rest_route( 'my-plugin/v1', '/posts', array(
* 'methods' => 'GET',
* 'callback' => 'my_plugin_get_posts',
* 'permission_callback' => '__return_true',
* ) );
* } );
*/
function register_rest_namespace( $route_namespace ) {
if ( ! is_string( $route_namespace ) ) {
_doing_it_wrong(
__FUNCTION__,
__( 'Namespace must be a string.' ),
'X.X.0'
);

return false;
}

if ( empty( $route_namespace ) ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
/* translators: %s: The namespace that was passed. */
__( 'Namespaces must be specified. Instead there seems to be an empty namespace \'%s\'.' ),
'<code>' . $route_namespace . '</code>'
),
'X.X.0'
);

return false;
}

$clean_namespace = trim( $route_namespace, '/' );

if ( $clean_namespace !== $route_namespace ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
/* translators: %s: The namespace that was passed. */
__( 'Namespace must not start or end with a slash. Instead namespace \'%s\' seems to contain a slash.' ),
'<code>' . $route_namespace . '</code>'
),
'X.X.0'
);
}

if ( ! did_action( 'rest_api_init' ) ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
/* translators: 1: rest_api_init, 2: The namespace that was passed. */
__( 'REST API namespaces must be registered on the %1$s action. Instead namespace \'%2$s\' was not registered on this action.' ),
'<code>rest_api_init</code>',
'<code>' . $route_namespace . '</code>'
),
'X.X.0'
);
}

rest_get_server()->register_lazy_loaded_namespace( $clean_namespace );

return true;
}

/**
* Registers a new field on an existing WordPress object type.
*
Expand Down Expand Up @@ -858,7 +946,7 @@ function rest_send_allow_header( $response, $server, $request ) {
return $response;
}

$routes = $server->get_routes();
$routes = $server->get_routes( '', $server::ROUTE_CONTEXT_LOADED_ONLY );

$allowed_methods = array();

Expand Down
149 changes: 140 additions & 9 deletions src/wp-includes/rest-api/class-wp-rest-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ class WP_REST_Server {
*/
protected $namespaces = array();

/**
* Lazily-loaded namespaces registered to the server.
*
* @since X.X.0
* @var array
*/
protected $lazy_namespaces = array();

/**
* Endpoints registered to the server.
*
Expand Down Expand Up @@ -95,6 +103,22 @@ class WP_REST_Server {
*/
protected $dispatching_requests = array();

/**
* Route context constant: Load all namespaces including lazy-loaded ones.
*
* @since X.X.0
* @var string
*/
public const ROUTE_CONTEXT_ALL = 'all';

/**
* Route context constant: Only return routes from already-loaded namespaces.
*
* @since X.X.0
* @var string
*/
public const ROUTE_CONTEXT_LOADED_ONLY = 'loaded_only';

/**
* Instantiates the REST server.
*
Expand Down Expand Up @@ -884,6 +908,92 @@ public function envelope_response( $response, $embed ) {
return rest_ensure_response( $envelope );
}

/**
* Register a namespace for lazy loading.
*
* @since X.X.0
*
* @param string $route_namespace The namespace to register for lazy loading.
*/
public function register_lazy_loaded_namespace( $route_namespace ) {
if ( ! isset( $this->lazy_namespaces[ $route_namespace ] ) ) {
$this->lazy_namespaces[ $route_namespace ] = false;
}
}

/**
* Load a specific lazy namespace.
*
* @since X.X.0
*
* @param string $route_namespace The namespace to load.
* @return bool True if loaded, false if not a lazy namespace.
*/
protected function load_lazy_namespace( $route_namespace ) {
if ( ! isset( $this->lazy_namespaces[ $route_namespace ] ) ) {
return false;
}

if ( $this->lazy_namespaces[ $route_namespace ] ) {
return true; // Already loaded
}

$this->lazy_namespaces[ $route_namespace ] = true;

/**
* Fires when a lazy-loaded REST API namespace is being loaded.
*
* This action is triggered when a lazy-loaded namespace needs to have its
* routes registered. Plugins should hook into this action to register
* their routes for the specific namespace being loaded.
*
* The dynamic portion of the hook name, `$route_namespace`, refers to
* the namespace being loaded (e.g., 'my-plugin/v1').
*
* @since X.X.0
*
* @example
* // Register routes when your namespace loads
* add_action( 'rest_lazy_load_namespace_my-plugin/v1', function() {
* register_rest_route( 'my-plugin/v1', '/posts', array(
* 'methods' => 'GET',
* 'callback' => 'my_plugin_get_posts',
* 'permission_callback' => '__return_true',
* ) );
* } );
*/
do_action( 'rest_lazy_load_namespace_' . $route_namespace );
Copy link
Author

@prettyboymp prettyboymp Oct 2, 2025

Choose a reason for hiding this comment

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

This somewhat breaks from convention around only using underscores for hooks since by their own convention namespaces are almost always going to contain dashes and slashes. Would it be better to convert these for the hook for the sake of convention or leave it as is?

Copy link
Member

Choose a reason for hiding this comment

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

This also isn't a recommended pattern anymore. The namespace should just be it's own parameter.

Copy link
Author

Choose a reason for hiding this comment

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

This also isn't a recommended pattern anymore. The namespace should just be it's own parameter.

@TimothyBJacobs, do you happen to have a reference to some relevant discussion around this? There seem to still be (relatively) recent hooks added with this pattern.

I would hate for every callback created for lazy route registration to have to start with if( '/my-plugin-namespace' === $route_namespace )....

Copy link
Member

Choose a reason for hiding this comment

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

Usually WP provides hooks for both. e.g. do_action( 'rest_lazy_load_namespace_' . $route_namespace, $route_namespace ); and do_action( 'rest_lazy_load_namespace', $route_namespace );


/**
* Fires when any lazy-loaded REST API namespace is being loaded.
*
* This action is triggered after the namespace-specific action
* {@see 'rest_lazy_load_namespace_$route_namespace'} has fired.
* It can be used to perform actions common to all lazy-loaded
* namespaces, such as logging or registering shared dependencies.
*
* @since X.X.0
*
* @param string $route_namespace The namespace being loaded (e.g., 'my-plugin/v1').
*/
do_action( 'rest_lazy_load_namespace', $route_namespace );

return true;
}

/**
* Load all lazy namespaces.
*
* @since X.X.0
*/
protected function load_all_lazy_namespaces() {
foreach ( $this->lazy_namespaces as $namespace => $has_loaded ) {
if ( ! $has_loaded ) {
$this->load_lazy_namespace( $namespace );
}
}
}

/**
* Registers a route to the server.
*
Expand Down Expand Up @@ -946,14 +1056,28 @@ public function register_route( $route_namespace, $route, $route_args, $override
* Note that the path regexes (array keys) must have @ escaped, as this is
* used as the delimiter with preg_match()
*
* @since 4.4.0
* @since 5.4.0 Added `$route_namespace` parameter.
*
* @param string $route_namespace Optionally, only return routes in the given namespace.
* @param string $route_context Optional. Route loading context. Accepts:
* - 'all' (default): Load all namespaces including lazy-loaded ones
* - 'loaded_only': Only return routes from already-loaded namespaces
*
* @return array `'/path/regex' => array( $callback, $bitmask )` or
* `'/path/regex' => array( array( $callback, $bitmask ), ...)`.
*
* @since 4.4.0
* @since 5.4.0 Added `$route_namespace` parameter.
* @since X.X.0 Added `$route_context` parameter.
*/
public function get_routes( $route_namespace = '' ) {
public function get_routes( $route_namespace = '', $route_context = self::ROUTE_CONTEXT_ALL ) {
if ( self::ROUTE_CONTEXT_LOADED_ONLY !== $route_context ) {
if ( $route_namespace ) {
// Load only the namespace requested
$this->load_lazy_namespace( $route_namespace );
} else {
$this->load_all_lazy_namespaces();
}
}

$endpoints = $this->endpoints;

if ( $route_namespace ) {
Expand Down Expand Up @@ -1155,16 +1279,18 @@ protected function match_request_to_handler( $request ) {

$with_namespace = array();

foreach ( $this->get_namespaces() as $namespace ) {
if ( str_starts_with( trailingslashit( ltrim( $path, '/' ) ), $namespace ) ) {
$all_registered_namespaces = array_keys( $this->namespaces + $this->lazy_namespaces );
foreach ( $all_registered_namespaces as $namespace ) {
if ( str_starts_with( trailingslashit( ltrim( $path, '/' ) ), trailingslashit( $namespace ) ) ) {
$with_namespace[] = $this->get_routes( $namespace );
}
}

if ( $with_namespace ) {
$routes = array_merge( ...$with_namespace );
} else {
$routes = $this->get_routes();
// Avoid loading all namespaces as none match.
$routes = $this->get_routes( '', self::ROUTE_CONTEXT_LOADED_ONLY );
}

foreach ( $routes as $route => $handlers ) {
Expand Down Expand Up @@ -1357,6 +1483,9 @@ protected function get_json_last_error() {
* @return WP_REST_Response The API root index data.
*/
public function get_index( $request ) {
// Pre-load all namespaces and endpoints.
$this->load_all_lazy_namespaces();

// General site data.
$available = array(
'name' => get_option( 'blogname' ),
Expand All @@ -1370,7 +1499,7 @@ public function get_index( $request ) {
'show_on_front' => get_option( 'show_on_front' ),
'namespaces' => array_keys( $this->namespaces ),
'authentication' => array(),
'routes' => $this->get_data_for_routes( $this->get_routes(), $request['context'] ),
'routes' => $this->get_data_for_routes( $this->get_routes( '', self::ROUTE_CONTEXT_LOADED_ONLY ), $request['context'] ),
);

$response = new WP_REST_Response( $available );
Expand Down Expand Up @@ -1515,6 +1644,8 @@ protected function add_image_to_index( WP_REST_Response $response, $image_id, $t
public function get_namespace_index( $request ) {
$namespace = $request['namespace'];

$this->load_lazy_namespace( $namespace );

if ( ! isset( $this->namespaces[ $namespace ] ) ) {
return new WP_Error(
'rest_invalid_namespace',
Expand All @@ -1524,7 +1655,7 @@ public function get_namespace_index( $request ) {
}

$routes = $this->namespaces[ $namespace ];
$endpoints = array_intersect_key( $this->get_routes(), $routes );
$endpoints = array_intersect_key( $this->get_routes( $namespace, self::ROUTE_CONTEXT_LOADED_ONLY ), $routes );

$data = array(
'namespace' => $namespace,
Expand Down
Loading
Loading