Skip to content

Commit ba55d86

Browse files
Route Registration: Add support for a routes folder to the build tool (#72950)
Co-authored-by: youknowriad <youknowriad@git.wordpress.org> Co-authored-by: ellatrix <ellatrix@git.wordpress.org>
1 parent b90079a commit ba55d86

File tree

18 files changed

+672
-82
lines changed

18 files changed

+672
-82
lines changed

.npmpackagejsonlintignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# By default, all `node_modules` are ignored.
2+
3+
build
4+
vendor
5+
wordpress
6+
routes
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
/**
3+
* Boot package route registration API.
4+
*
5+
* @package gutenberg
6+
*/
7+
8+
/**
9+
* Registered routes storage.
10+
*
11+
* @var array
12+
*/
13+
global $gutenberg_boot_routes;
14+
$gutenberg_boot_routes = array();
15+
16+
/**
17+
* Register a boot route.
18+
*
19+
* @param string $path Route path (e.g., "/").
20+
* @param string $content_module Script module ID for route content (stage/inspector).
21+
* @param string $route_module Optional script module ID for route lifecycle (beforeLoad/loader).
22+
*/
23+
function gutenberg_register_boot_route( $path, $content_module, $route_module = null ) {
24+
global $gutenberg_boot_routes;
25+
26+
$route = array(
27+
'path' => $path,
28+
'content_module' => $content_module,
29+
);
30+
31+
// Only include route_module if it's not empty.
32+
if ( ! empty( $route_module ) ) {
33+
$route['route_module'] = $route_module;
34+
}
35+
36+
$gutenberg_boot_routes[] = $route;
37+
}
38+
39+
/**
40+
* Get all registered boot routes.
41+
*
42+
* @return array Array of registered routes.
43+
*/
44+
function gutenberg_get_boot_routes() {
45+
global $gutenberg_boot_routes;
46+
return $gutenberg_boot_routes;
47+
}

lib/experimental/boot/boot.php

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
// Include route and menu APIs.
99
require_once __DIR__ . '/boot-menu-items.php';
10+
require_once __DIR__ . '/boot-routes.php';
1011
require_once __DIR__ . '/preload.php';
1112

1213
/**
@@ -37,6 +38,7 @@ function gutenberg_boot_admin_page() {
3738

3839
// Get routes and menu items.
3940
$menu_items = gutenberg_get_boot_menu_items();
41+
$routes = gutenberg_get_boot_routes();
4042

4143
// Get boot module asset file for dependencies.
4244
$asset_file = gutenberg_dir_path() . 'build/modules/boot/index.min.asset.php';
@@ -52,8 +54,9 @@ function gutenberg_boot_admin_page() {
5254
wp_add_inline_script(
5355
'gutenberg-boot-prerequisites',
5456
sprintf(
55-
'import("@wordpress/boot").then(mod => mod.init({menuItems: %s}));',
56-
wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
57+
'import("@wordpress/boot").then(mod => mod.init({menuItems: %s, routes: %s}));',
58+
wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
59+
wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
5760
)
5861
);
5962

@@ -66,14 +69,35 @@ function ( $handle ) {
6669
);
6770
wp_register_style( 'gutenberg-boot-prerequisites', false, $style_dependencies, $asset['version'] );
6871

72+
// Build dependencies for gutenberg-boot module.
73+
$boot_dependencies = array(
74+
array(
75+
'import' => 'static',
76+
'id' => '@wordpress/boot',
77+
),
78+
);
79+
80+
// Add all registered routes as dependencies.
81+
foreach ( $routes as $route ) {
82+
if ( isset( $route['route_module'] ) ) {
83+
$boot_dependencies[] = array(
84+
'import' => 'static',
85+
'id' => $route['route_module'],
86+
);
87+
}
88+
if ( isset( $route['content_module'] ) ) {
89+
$boot_dependencies[] = array(
90+
'import' => 'dynamic',
91+
'id' => $route['content_module'],
92+
);
93+
}
94+
}
95+
6996
// Dummy script module to ensure dependencies are loaded.
7097
wp_register_script_module(
7198
'gutenberg-boot',
7299
gutenberg_url( 'lib/experimental/boot/loader.js' ),
73-
array(
74-
'import' => 'static',
75-
'id' => '@wordpress/boot',
76-
)
100+
$boot_dependencies
77101
);
78102

79103
// Enqueue the boot scripts a,d styles.

package-lock.json

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@
303303
]
304304
},
305305
"workspaces": [
306-
"packages/*"
306+
"packages/*",
307+
"routes/*"
307308
]
308309
}

packages/boot/src/components/app/router.tsx

Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@ import type { ComponentType } from 'react';
1616
* WordPress dependencies
1717
*/
1818
import { __ } from '@wordpress/i18n';
19-
import { lazy, Suspense, useMemo } from '@wordpress/element';
19+
import { lazy, useState, useEffect } from '@wordpress/element';
2020
import { Page } from '@wordpress/admin-ui';
2121

2222
/**
2323
* Internal dependencies
2424
*/
2525
import Root from '../root';
2626
import type { Route, RouteLoaderContext } from '../../store/types';
27-
import * as homeRoute from '../home';
2827

2928
// Not found component
3029
function NotFoundComponent() {
@@ -48,16 +47,12 @@ function RouteComponent( {
4847
<>
4948
{ Stage && (
5049
<div className="boot-layout__stage">
51-
<Suspense fallback={ <div>Loading...</div> }>
52-
<Stage />
53-
</Suspense>
50+
<Stage />
5451
</div>
5552
) }
5653
{ Inspector && (
5754
<div className="boot-layout__inspector">
58-
<Suspense fallback={ <div>Loading...</div> }>
59-
<Inspector />
60-
</Suspense>
55+
<Inspector />
6156
</div>
6257
) }
6358
</>
@@ -71,16 +66,14 @@ function RouteComponent( {
7166
* @param parentRoute Parent route.
7267
* @return Tanstack Route.
7368
*/
74-
function createRouteFromDefinition( route: Route, parentRoute: AnyRoute ) {
75-
if ( ! route.content && ! route.content_module ) {
76-
throw new Error( 'Route must have content or content_module property' );
77-
}
78-
69+
async function createRouteFromDefinition(
70+
route: Route,
71+
parentRoute: AnyRoute
72+
) {
7973
// Create lazy components for stage and inspector surfaces
80-
const SurfacesModule = route.content
81-
? () => <RouteComponent { ...route.content } />
82-
: lazy( async () => {
83-
const module = await import( route.content_module as string );
74+
const SurfacesModule = route.content_module
75+
? lazy( async () => {
76+
const module = await import( route.content_module! );
8477
// Return a component that renders the surfaces
8578
return {
8679
default: () => (
@@ -90,27 +83,39 @@ function createRouteFromDefinition( route: Route, parentRoute: AnyRoute ) {
9083
/>
9184
),
9285
};
93-
} );
86+
} )
87+
: () => null;
88+
89+
// Load route module for lifecycle functions if specified
90+
let routeConfig: {
91+
beforeLoad?: ( context: RouteLoaderContext ) => void | Promise< void >;
92+
loader?: ( context: RouteLoaderContext ) => Promise< unknown >;
93+
} = {};
94+
95+
if ( route.route_module ) {
96+
const module = await import( route.route_module );
97+
routeConfig = module.route || {};
98+
}
9499

95100
return createRoute( {
96101
getParentRoute: () => parentRoute,
97102
path: route.path,
98-
beforeLoad: route.beforeLoad
103+
beforeLoad: routeConfig.beforeLoad
99104
? async ( opts: any ) => {
100105
const context: RouteLoaderContext = {
101106
params: opts.params || {},
102107
search: opts.search || {},
103108
};
104-
await route.beforeLoad?.( context );
109+
await routeConfig.beforeLoad!( context );
105110
}
106111
: undefined,
107-
loader: route.loader
112+
loader: routeConfig.loader
108113
? async ( opts: any ) => {
109114
const context: RouteLoaderContext = {
110115
params: opts.params || {},
111116
search: opts.search || {},
112117
};
113-
return await route.loader?.( context );
118+
return await routeConfig.loader!( context );
114119
}
115120
: undefined,
116121
component: SurfacesModule,
@@ -123,22 +128,15 @@ function createRouteFromDefinition( route: Route, parentRoute: AnyRoute ) {
123128
* @param routes Routes definition.
124129
* @return Router tree.
125130
*/
126-
function createRouteTree( routes: Route[] ) {
131+
async function createRouteTree( routes: Route[] ) {
127132
const rootRoute = createRootRoute( {
128133
component: Root,
129134
context: () => ( {} ),
130135
} );
131136

132-
// Create home route using the route system
133-
const homeRouteDefinition: Route = {
134-
path: '/',
135-
content: homeRoute,
136-
};
137-
138-
// Create routes from definitions including home
139-
const allRoutes = [ homeRouteDefinition, ...routes ];
140-
const dynamicRoutes = allRoutes.map( ( route ) =>
141-
createRouteFromDefinition( route, rootRoute )
137+
// Create routes from definitions
138+
const dynamicRoutes = await Promise.all(
139+
routes.map( ( route ) => createRouteFromDefinition( route, rootRoute ) )
142140
);
143141

144142
return rootRoute.addChildren( dynamicRoutes );
@@ -166,17 +164,35 @@ interface RouterProps {
166164
}
167165

168166
export default function Router( { routes }: RouterProps ) {
169-
// Create router with dynamic routes
170-
const router = useMemo( () => {
171-
const history = createPathHistory();
172-
const routeTree = createRouteTree( routes );
173-
174-
return createRouter( {
175-
history,
176-
routeTree,
177-
defaultNotFoundComponent: NotFoundComponent,
178-
} );
167+
const [ router, setRouter ] = useState< any >( null );
168+
169+
useEffect( () => {
170+
let cancelled = false;
171+
172+
async function initializeRouter() {
173+
const history = createPathHistory();
174+
const routeTree = await createRouteTree( routes );
175+
176+
if ( ! cancelled ) {
177+
const newRouter = createRouter( {
178+
history,
179+
routeTree,
180+
defaultNotFoundComponent: NotFoundComponent,
181+
} );
182+
setRouter( newRouter );
183+
}
184+
}
185+
186+
initializeRouter();
187+
188+
return () => {
189+
cancelled = true;
190+
};
179191
}, [ routes ] );
180192

193+
if ( ! router ) {
194+
return <div>Loading routes...</div>;
195+
}
196+
181197
return <RouterProvider router={ router } />;
182198
}

packages/boot/src/store/types.ts

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export interface RouteLoaderContext {
4040

4141
/**
4242
* Route configuration interface.
43-
* All routes must specify a content_module that exports surfaces.
43+
* Routes specify content_module for surfaces and optionally route_module for lifecycle functions.
4444
*/
4545
export interface Route {
4646
/**
@@ -49,28 +49,19 @@ export interface Route {
4949
path: string;
5050

5151
/**
52-
* Hook executed before the route loads.
53-
* Can throw redirect() or notFound() to prevent navigation.
54-
*/
55-
beforeLoad?: ( context: RouteLoaderContext ) => void | Promise< void >;
56-
57-
/**
58-
* Async data preloading function.
59-
* The returned data is available to route components.
60-
*/
61-
loader?: ( context: RouteLoaderContext ) => Promise< unknown >;
62-
63-
/**
64-
* Module path for lazy loading the route's surfaces.
65-
* The module must export: RouteSurfaces
52+
* Module path for lazy loading the route's surfaces (stage, inspector).
53+
* The module must export: RouteSurfaces (stage and/or inspector components)
6654
* This enables code splitting for better performance.
6755
*/
6856
content_module?: string;
6957

7058
/**
71-
* If the route is not lazy loaded, it can define a static "content" property.
59+
* Module path for route lifecycle functions.
60+
* The module should export a named export `route` containing:
61+
* - beforeLoad?: Pre-navigation hook (authentication, validation, redirects)
62+
* - loader?: Data preloading function
7263
*/
73-
content?: RouteSurfaces;
64+
route_module?: string;
7465
}
7566

7667
export interface State {

0 commit comments

Comments
 (0)