๐บ๏ธ Lazy Route Discovery (Fog of War) #11113
Replies: 7 comments 12 replies
-
Are you planning to expose |
Beta Was this translation helpful? Give feedback.
-
This would be amazing! |
Beta Was this translation helpful? Give feedback.
-
This is the key to microfrontends returning to react-router after v6.4, which is apt given the first examples were based on react-router. This will make micro-frontends extremely easy to implement, teams just need to build their micro-frontend app as a library that exports its routes by default, then deploy it to a static file host. The main app shell can then use import maps to define the location and version of each app to use along with shared dependencies: <script type="importmap">
{
"imports": {
"@org-name/dashboard": "https://assets.mycompany.com/libs/@org-name/dashboard/v1.7.0/dashboard.js",
"@org-name/account": "https://assets.mycompany.com/libs/@org-name/account/v2.4.0/account.js",
"@org-name/settings": "https://assets.mycompany.com/libs/@org-name/settings/v1.0.5/settings.js",
"react": "https://esm.sh/react@^18",
"react-dom": "https://esm.sh/react-dom@^18",
"react-router-dom": "https://esm.sh/react-router-dom@^6",
...
},
}
</script> Each micro-frontend app just needs to mark shared dependencies as external: import { defineConfig } from "vite";
export default defineConfig({
build: {
rollupOptions: {
external: ["react", "react-dom", "react-router-dom", ...],
},
},
}); The independently deployed apps can then be loaded using the new lazy route discovery: let router = createBrowserRouter([
{
path: "/",
Component: Home,
},
{
path: "/dashboard",
children: () => import("@org-name/dashboard"),
},
{
path: "/account",
children: () => import("@org-name/account"),
},
{
path: "/settings",
children: () => import("@org-name/settings"),
},
]); So simple! Micro-frontends are standard react-router apps that just export their routes, web standards based, no microfrontend frameworks like single-spa to learn. I see Remix SPA rather than react-router as the future for such apps though, so it would be really useful to have the ability through a custom vite plugin or by default for Remix SPA's routes to get exported in a way that can be dynamically imported as above in the import map. The only issue I foresee is how to incrementally upgrade each microfrontend to a new react-router/remix major version, it would likely take some coordination given the route tree needs to be shared. All apps would need to release a new major version before the main shell app upgrades and uses the new major versions. Maybe at a certain scale rendering nested RouterProvider's is a better option so that each app can have its own independent router and version. Navigating outside of a microfrontend would need to be done by getting each app's router like: import { router } from "@org-name/shell";
<a onClick={(e) => { router.navigate("/"); e.preventDefault(); }} />
// or
import { Link } from "@org-name/components";
import { router } from "@org-name/settings";
<Link router={router} to="/settings/email">Change Email</Link> Anyway, just my thoughts on one of the many opportunities this feature enables, thanks for all the great work! |
Beta Was this translation helpful? Give feedback.
-
I don't see it mentioned here yet, but I hope |
Beta Was this translation helpful? Give feedback.
-
Really interested in this! We currently use a browser (data) router and split features into their own directories/libs where each exports a routes array. We're up to 40 different domain aggregates now in a monorepo that all need to be built at once because the router needs to know about all of them on instantiation. This would let us publish each as an independent library and load it only when the user navigates to one of their routes. Further, I've been wanting to support 3rd party devs contributing application features and this would let us delegate hosting them as micro-frontends. In a multi-tenant, multi-provider scenario (essentially a two-sided marketplace) having the ability to conditionally, dynamically load RR routes would support getting rid of tons of manual patchwork each time an app/plugin wants to push a release |
Beta Was this translation helpful? Give feedback.
-
Wanted to share my implementation for microfrontend with react-router in my cases, the routes only discoverable on re-render, and merged by joining every route on the metadata const defaultMetadata = {
name: "default",
enabled: true,
routes: [
{ path: "/", element: <Navigate to="/dashboard" /> },
] satisfies RouteObject[],
} satisfies FeatureMetadata;
export default [
defaultMetadata,
// ... rest
] satisfies FeatureMetadata[]; with simple yet working provider hack const remoteItems = backgroundRemote.remotes.map((item) => item.remotes());
const accessibleRoutes = remoteItems.map( ({route} ) => route);
const routes = [...accessibleRoutes]; this implementation will work on most cases but takes so much work to be done on the shell app to merge routes. perhaps when Fog of War is implemented, the pattern for this will be simplified. |
Beta Was this translation helpful? Give feedback.
-
I just wanted to share some perspective having tried this feature in production, so far the two biggest issues are:
|
Beta Was this translation helpful? Give feedback.
-
Lazy Route Discovery (Fog of War)
Background
Prior to using "Data Routers" in React Router you could compose together your route trees at runtime (during the render cycle) via
<Routes>
/useRoutes
. This allowed developers to keep the application code cleanly separated by vertical domains:This allowed users to:
app.js
to a manageable sizeReact.lazy()
/<React.Suspense>
Note: This differentiation between route definition and route implementation is important.
When we brought the Remix Data APIs over to React Router in v6.4.0, we introduced the new
createBrowserRouter
/RouterProvider
APIs. We love the new data apis and how much they clean up our render code by letting React Router handle data loading, navigations, errors, interruptions, etc. - but one downside of the initial implementation is that the router needed to know about all routes up front for the Data APIs.Therefore, while you can still use
<Routes>
/useRoutes
to lazily define routes for your application, you lose access to all of the new Data APIs in those lazily-defined routes because they're not discovered until after the data submission/loading has been completed.In
v6.9.0
, we released support forroute.lazy
which allowed you to code-split and lazily load your route implementations. This gets us pretty far by getting the vast majority of your "app code" out of the cirtical bundle, but still requires all of your route definitions to be defined up front when you callcreateBrowserRouter
:This is not overly problematic for small-to-medium sized apps with 10, 50, or even 100 routes, but at scale when you get into the hundreds and thousands of routes - this up-front definition can grow prohibitively large.
It also introduces other technical and organizational problems:
Proposal
The implementation of
route.lazy
worked as well as it did because we have an async router that is decoupled from the render cycle. Once we knew a route matched (which we could do synchronously due to top-level definitions), we could enter oursubmitting
/loading
state and fetch the route implementation viaroute.lazy
and then proceed with calling actions/loaders and rendering the resulting route component. Thus, the current router navigation cycle looks like:Note that both
submitting
andloading
are optional in this flow. If you perform a GET navigation,submitting
is never entered. And if there are no loaders to run for your navigation, it never enters theloading
state.state = discovering
We can take this one step farther and introduce a new (optional) asynchronous route discovery step to the router:
This
discovering
state would be entered if we needed to load additional sub-portions of the route tree. Routes will indicate this by providing a function forchildren
, instead of the normal array:By defining
children
as a function, React Router will know when it's only matched a partial route, and has additional route discovery to perform, and will enter adiscovering
state while it executes thechildren()
function. The return value is expected to be exactly the same format as you'd define in achildren
array - and can leverage all the same features, includingroute.lazy
and nestedroute.children
.Just like
route.lazy
-route.children
will only load child routes one time, and once loaded and patched into the route-tree, there will be no discovering phase on subsequent navigations to those child routes.Tip
This feature is often referred to as "Fog of War" because similar to how video games expand the "world" as you move around - the router would be expanding it's routing tree as the user navigated around the app - but would only ever end up loading portions of the tree that the user visited.
Benefits
This provides parity with non-Data Router APIs where both route definition and route implementation of data-aware routes can be lazily loaded and code-split, opening up possibilities for lazy loading and code-splitting entire sub-portions of the application for technical or organization reasons.
Technical/Implementation Notes
discovering
state would apply to both navigations and fetcherschildren()
functions would not be valid on splat routes, which by definition indicate you've reached a leaf of the route treechildren()
- let it complete in the background and patch the route tree so that if the user navigates again to that location the routes are availablechildren()
function throws - we'd bubble to the nearest error boundary on currently-known routes. I think we'd let it execute again on the next navigation?route.children
androute.lazy
together should work allowing users to continue to code split route definition and implementation independently if they choosechildren
functionRemix
In Remix, the goal would be to keep this as an implementation detail - but leverage it so that we can significantly shrink the size of the Remix Manifest shipped to the browser. Remix knows all of your routes at build time so we could chunk off by route portions and automatically generate the React Router
children()
functions.The initial implementation could be quite simple and chunk by top-level URL segments, but eventually we could go further or potentially allow some form of configuration by Remix app developers. We'd like to keep it internal to start though since eventually, when using a server in Remix, RSC will likely eliminate this manifest problem entirely. We don't want to introduce a new API just to fix it differently with an RSC solution down the road.
It's also worth noting that Remix was originally designed this way, but shifted to using a manifest to allow for prefetching - since Remix can't prefetch routes if it doesn't even know if they exist.
In order to keep prefetching around, we'd add the ability to discover routes based on the Links that are rendered:
This way,
Link
's' rendered on the page could be optimistically "discovered" and added to the route tree, such that by the time the user hovers on theLink
, any applicable destination route children have been loaded for the prefetching. I would also vote thatdiscover="render"
is the default and you have to opt-out viadiscover="none"
if you want to delay discovery until the link is clicked - which introduces a network waterfall (discover route, then run loader).Beta Was this translation helpful? Give feedback.
All reactions