diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php
index 836e0e5ec8a23..d1bc2ae76727f 100644
--- a/src/wp-includes/rest-api.php
+++ b/src/wp-includes/rest-api.php
@@ -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\'.' ),
+ '' . $route_namespace . ''
+ ),
+ '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.' ),
+ '' . $route_namespace . ''
+ ),
+ '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.' ),
+ 'rest_api_init',
+ '' . $route_namespace . ''
+ ),
+ 'X.X.0'
+ );
+ }
+
+ rest_get_server()->register_lazy_loaded_namespace( $clean_namespace );
+
+ return true;
+}
+
/**
* Registers a new field on an existing WordPress object type.
*
@@ -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();
diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php
index 8ffda57f15853..66d290098a62f 100644
--- a/src/wp-includes/rest-api/class-wp-rest-server.php
+++ b/src/wp-includes/rest-api/class-wp-rest-server.php
@@ -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.
*
@@ -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.
*
@@ -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 );
+
+ /**
+ * 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.
*
@@ -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 ) {
@@ -1155,8 +1279,9 @@ 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 );
}
}
@@ -1164,7 +1289,8 @@ protected function match_request_to_handler( $request ) {
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 ) {
@@ -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' ),
@@ -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 );
@@ -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',
@@ -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,
diff --git a/tests/phpunit/tests/rest-api/rest-server.php b/tests/phpunit/tests/rest-api/rest-server.php
index 692c363ac7595..a52c578c362fd 100644
--- a/tests/phpunit/tests/rest-api/rest-server.php
+++ b/tests/phpunit/tests/rest-api/rest-server.php
@@ -1377,6 +1377,20 @@ public function test_get_namespace_index() {
),
)
);
+ $server->register_route(
+ 'test/example/extended',
+ '/test/example/extended/some-route',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => '__return_true',
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => '__return_true',
+ ),
+ )
+ );
$server->register_route(
'test/another',
'/test/another/route',
@@ -1402,6 +1416,7 @@ public function test_get_namespace_index() {
// ...and none we don't.
$this->assertArrayNotHasKey( '/test/another/route', $data['routes'] );
+ $this->assertArrayNotHasKey( '/test/example/extended/extended-route', $data['routes'] );
}
public function test_get_namespaces() {
@@ -2611,6 +2626,571 @@ public function test_prefers_developer_defined_target_hints() {
$this->assertSame( array( 'GET', 'PUT' ), $link['targetHints']['allow'] );
}
+ /**
+ * Verify route matching priority is based on the order of registration.
+ *
+ * Documents existing route priority behavior that the lazy loading
+ * changes to match_request_to_handler() must preserve.
+ *
+ * @ticket 63946
+ */
+ public function test_route_priority_registration_order() {
+ // Register general route first.
+ register_rest_route(
+ 'priority-test/v1',
+ '/items/(?P[\\w-]+)',
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => function ( $req ) {
+ return array(
+ 'handler' => 'general',
+ 'id' => $req['id'],
+ );
+ },
+ 'permission_callback' => '__return_true',
+ ),
+ )
+ );
+ // Register specific route after general.
+ register_rest_route(
+ 'priority-test/v1',
+ '/items/special',
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'handler' => 'specific' );
+ },
+ 'permission_callback' => '__return_true',
+ ),
+ )
+ );
+
+ // 'special' should match the general route (registered first)
+ $request = new WP_REST_Request( 'GET', '/priority-test/v1/items/special' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+ $this->assertEquals( 'general', $data['handler'] );
+ }
+
+ /**
+ * Verify route matching priority is based on the order of registration.
+ *
+ * Documents existing route priority behavior that the lazy loading
+ * changes to match_request_to_handler() must preserve.
+ *
+ * @ticket 63946
+ */
+ public function test_route_priority_reverse_registration_order() {
+ // Register specific route first.
+ register_rest_route(
+ 'priority-test/v1',
+ '/items/special',
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'handler' => 'specific' );
+ },
+ 'permission_callback' => '__return_true',
+ ),
+ )
+ );
+ // Register general route after specific.
+ register_rest_route(
+ 'priority-test/v1',
+ '/items/(?P[\\w-]+)',
+ array(
+ array(
+ 'methods' => 'GET',
+ 'callback' => function ( $req ) {
+ return array(
+ 'handler' => 'general',
+ 'id' => $req['id'],
+ );
+ },
+ 'permission_callback' => '__return_true',
+ ),
+ )
+ );
+
+ $request = new WP_REST_Request( 'GET', '/priority-test/v1/items/special' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+ $this->assertEquals( 'specific', $data['handler'] );
+ }
+
+
+ /**
+ * @ticket 63946
+ */
+ public function test_route_override_parameter_replaces_callback() {
+ // Register initial route.
+ register_rest_route(
+ 'override-test/v1',
+ '/thing',
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'version' => 'original' );
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+ // Register again with override.
+ register_rest_route(
+ 'override-test/v1',
+ '/thing',
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'version' => 'override' );
+ },
+ 'permission_callback' => '__return_true',
+ ),
+ true
+ );
+
+ $request = new WP_REST_Request( 'GET', '/override-test/v1/thing' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+ $this->assertEquals( 'override', $data['version'] );
+ }
+
+ /**
+ * Test basic lazy namespace registration.
+ *
+ * @ticket 63946
+ * */
+ public function test_lazy_namespace_registration() {
+ $server = rest_get_server();
+
+ // Register a lazy namespace.
+ $server->register_lazy_loaded_namespace( 'lazy-test/v1' );
+
+ // Lazy-loaded namespaces don't appear in get_namespaces() until they are loaded and their routes are registered.
+ $namespaces = $server->get_namespaces();
+ $this->assertNotContains( 'lazy-test/v1', $namespaces );
+ }
+
+ /**
+ * Test lazy namespace loading via action hook.
+ *
+ * @ticket 63946
+ */
+ public function test_lazy_namespace_loading_via_action() {
+ $server = rest_get_server();
+ $server->register_lazy_loaded_namespace( 'lazy-action/v1' );
+
+ $action_called = false;
+ $action_namespace = '';
+
+ // Hook to simulate route registration.
+ add_action(
+ 'rest_lazy_load_namespace_lazy-action/v1',
+ function () use ( &$action_called, &$action_namespace ) {
+ $action_called = true;
+ $action_namespace = 'lazy-action/v1';
+
+ // Register actual routes when lazy loading.
+ register_rest_route(
+ 'lazy-action/v1',
+ '/test',
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'loaded' => true );
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+ );
+
+ // Trigger lazy loading by requesting routes.
+ $routes = $server->get_routes( 'lazy-action/v1' );
+
+ // Action should have been called.
+ $this->assertTrue( $action_called );
+ $this->assertEquals( 'lazy-action/v1', $action_namespace );
+
+ // Routes should now be available.
+ $this->assertArrayHasKey( '/lazy-action/v1/test', $routes );
+ }
+
+ /**
+ * Test that lazy loading only happens once per namespace.
+ *
+ * @ticket 63946
+ */
+ public function test_lazy_namespace_loads_only_once() {
+ $server = rest_get_server();
+ $server->register_lazy_loaded_namespace( 'lazy-once/v1' );
+
+ $load_count = 0;
+
+ add_action(
+ 'rest_lazy_load_namespace_lazy-once/v1',
+ function () use ( &$load_count ) {
+ $load_count++;
+ register_rest_route(
+ 'lazy-once/v1',
+ '/test',
+ array(
+ 'methods' => 'GET',
+ 'callback' => '__return_true',
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+ );
+
+ // Call multiple times that should trigger loading.
+ $server->get_routes( 'lazy-once/v1' );
+ $server->get_routes( 'lazy-once/v1' );
+
+ // Should only load once.
+ $this->assertEquals( 1, $load_count );
+ }
+
+ /**
+ * Test get_routes() with empty namespace loads all lazy namespaces.
+ *
+ * @ticket 63946
+ */
+ public function test_get_routes_empty_namespace_loads_all_lazy() {
+ $server = rest_get_server();
+
+ // Register multiple lazy namespaces.
+ $server->register_lazy_loaded_namespace( 'lazy-all-1/v1' );
+ $server->register_lazy_loaded_namespace( 'lazy-all-2/v1' );
+
+ $loaded_namespaces = array();
+
+ add_action(
+ 'rest_lazy_load_namespace_lazy-all-1/v1',
+ function () use ( &$loaded_namespaces ) {
+ $loaded_namespaces[] = 'lazy-all-1/v1';
+ register_rest_route(
+ 'lazy-all-1/v1',
+ '/test1',
+ array(
+ 'methods' => 'GET',
+ 'callback' => '__return_true',
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+ );
+
+ add_action(
+ 'rest_lazy_load_namespace_lazy-all-2/v1',
+ function () use ( &$loaded_namespaces ) {
+ $loaded_namespaces[] = 'lazy-all-2/v1';
+ register_rest_route(
+ 'lazy-all-2/v1',
+ '/test2',
+ array(
+ 'methods' => 'GET',
+ 'callback' => '__return_true',
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+ );
+
+ $routes = $server->get_routes();
+
+ // Both namespaces should have been loaded.
+ $this->assertContains( 'lazy-all-1/v1', $loaded_namespaces );
+ $this->assertContains( 'lazy-all-2/v1', $loaded_namespaces );
+ $this->assertArrayHasKey( '/lazy-all-1/v1/test1', $routes );
+ $this->assertArrayHasKey( '/lazy-all-2/v1/test2', $routes );
+ }
+
+ /**
+ * Test route dispatch triggers lazy loading for matching namespace.
+ *
+ * @ticket 63946
+ */
+ public function test_route_dispatch_triggers_lazy_loading() {
+ $server = rest_get_server();
+ $server->register_lazy_loaded_namespace( 'lazy-dispatch/v1' );
+
+ $dispatched = false;
+
+ add_action(
+ 'rest_lazy_load_namespace_lazy-dispatch/v1',
+ function () use ( &$dispatched ) {
+ $dispatched = true;
+ register_rest_route(
+ 'lazy-dispatch/v1',
+ '/endpoint',
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'success' => true );
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+ );
+
+ // Make a request that should trigger lazy loading.
+ $request = new WP_REST_Request( 'GET', '/lazy-dispatch/v1/endpoint' );
+ $response = $server->dispatch( $request );
+
+ $this->assertTrue( $dispatched );
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertTrue( $data['success'] );
+ }
+
+ /**
+ * Test mixed regular and lazy namespaces work together.
+ *
+ * @ticket 63946
+ */
+ public function test_mixed_regular_and_lazy_namespaces() {
+ $server = rest_get_server();
+
+ // Register regular namespace.
+ register_rest_route(
+ 'regular/v1',
+ '/test',
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'type' => 'regular' );
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+
+ // Register lazy namespace.
+ $server->register_lazy_loaded_namespace( 'lazy-mixed/v1' );
+
+ add_action(
+ 'rest_lazy_load_namespace_lazy-mixed/v1',
+ function () {
+ register_rest_route(
+ 'lazy-mixed/v1',
+ '/test',
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'type' => 'lazy' );
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+ );
+
+ // Test regular namespace works.
+ $request = new WP_REST_Request( 'GET', '/regular/v1/test' );
+ $response = $server->dispatch( $request );
+ $data = $response->get_data();
+ $this->assertEquals( 'regular', $data['type'] );
+
+ // Test lazy namespace works.
+ $request = new WP_REST_Request( 'GET', '/lazy-mixed/v1/test' );
+ $response = $server->dispatch( $request );
+ $data = $response->get_data();
+ $this->assertEquals( 'lazy', $data['type'] );
+ }
+
+ /**
+ * Test same namespace registered both lazy and regular.
+ *
+ * @ticket 63946
+ */
+ public function test_same_namespace_lazy_and_regular() {
+ $server = rest_get_server();
+
+ // Register regular route first.
+ register_rest_route(
+ 'shared/v1',
+ '/regular',
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'source' => 'regular' );
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+
+ // Register lazy namespace with same base.
+ $server->register_lazy_loaded_namespace( 'shared/v1' );
+
+ add_action(
+ 'rest_lazy_load_namespace_shared/v1',
+ function () {
+ register_rest_route(
+ 'shared/v1',
+ '/lazy',
+ array(
+ 'methods' => 'GET',
+ 'callback' => function () {
+ return array( 'source' => 'lazy' );
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+ );
+
+ // Both routes should work.
+ $request = new WP_REST_Request( 'GET', '/shared/v1/regular' );
+ $response = $server->dispatch( $request );
+ $data = $response->get_data();
+ $this->assertEquals( 'regular', $data['source'] );
+
+ $request = new WP_REST_Request( 'GET', '/shared/v1/lazy' );
+ $response = $server->dispatch( $request );
+ $data = $response->get_data();
+ $this->assertEquals( 'lazy', $data['source'] );
+ }
+
+ /**
+ * Test root index loads all lazy namespaces.
+ *
+ * @ticket 63946
+ */
+ public function test_root_index_loads_all_lazy_namespaces() {
+ $server = rest_get_server();
+ $server->register_lazy_loaded_namespace( 'lazy-index/v1' );
+
+ $loaded = false;
+
+ add_action(
+ 'rest_lazy_load_namespace_lazy-index/v1',
+ function () use ( &$loaded ) {
+ $loaded = true;
+ register_rest_route(
+ 'lazy-index/v1',
+ '/test',
+ array(
+ 'methods' => 'GET',
+ 'callback' => '__return_true',
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+ );
+
+ // Request root index.
+ $request = new WP_REST_Request( 'GET', '/' );
+ $response = $server->dispatch( $request );
+
+ $this->assertTrue( $loaded );
+ $data = $response->get_data();
+ $this->assertContains( 'lazy-index/v1', $data['namespaces'] );
+ }
+
+ /**
+ * Test namespace index triggers lazy loading.
+ *
+ * @ticket 63946
+ */
+ public function test_namespace_index_triggers_lazy_loading() {
+ $server = rest_get_server();
+ $server->register_lazy_loaded_namespace( 'lazy-ns-index/v1' );
+
+ $loaded = false;
+
+ add_action(
+ 'rest_lazy_load_namespace_lazy-ns-index/v1',
+ function () use ( &$loaded ) {
+ $loaded = true;
+ register_rest_route(
+ 'lazy-ns-index/v1',
+ '/test',
+ array(
+ 'methods' => 'GET',
+ 'callback' => '__return_true',
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+ );
+
+ // Request namespace index.
+ $request = new WP_REST_Request( 'GET', '/lazy-ns-index/v1' );
+ $response = $server->dispatch( $request );
+
+ $this->assertTrue( $loaded );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertEquals( 'lazy-ns-index/v1', $data['namespace'] );
+ $this->assertArrayHasKey( '/lazy-ns-index/v1/test', $data['routes'] );
+ }
+
+ /**
+ * Test lazy namespace doesn't load when not needed.
+ *
+ * @ticket 63946
+ */
+ public function test_lazy_namespace_no_unnecessary_loading() {
+ $server = rest_get_server();
+ $server->register_lazy_loaded_namespace( 'lazy-unused/v1' );
+
+ $loaded = false;
+
+ add_action(
+ 'rest_lazy_load_namespace_lazy-unused/v1',
+ function () use ( &$loaded ) {
+ $loaded = true;
+ }
+ );
+
+ // Register and request a different namespace.
+ register_rest_route(
+ 'other/v1',
+ '/test',
+ array(
+ 'methods' => 'GET',
+ 'callback' => '__return_true',
+ 'permission_callback' => '__return_true',
+ )
+ );
+
+ $request = new WP_REST_Request( 'GET', '/other/v1/test' );
+ $response = $server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertFalse( $loaded, 'Lazy namespace should not load when not needed' );
+ }
+
+ /**
+ * Test double registration of same lazy namespace.
+ *
+ * @ticket 63946
+ */
+ public function test_double_lazy_namespace_registration() {
+ $server = rest_get_server();
+
+ // Register same namespace twice.
+ $server->register_lazy_loaded_namespace( 'lazy-double/v1' );
+ $server->register_lazy_loaded_namespace( 'lazy-double/v1' );
+
+ $times_loaded = 0;
+ add_action(
+ 'rest_lazy_load_namespace_lazy-double/v1',
+ function () use ( &$times_loaded ) {
+ ++$times_loaded;
+ }
+ );
+
+ $server->get_routes();
+
+ $this->assertEquals( 1, $times_loaded, 'A namespace registered multiple times should only trigger the load action once.' );
+ }
+
public function _validate_as_integer_123( $value, $request, $key ) {
if ( ! is_int( $value ) ) {
return new WP_Error( 'some-error', 'This is not valid!' );