Repository navigation
Application-Controlled Route Discovery #15590
Replies: 7 comments 11 replies
|
Preserving the original config-mode proposal for reference. The discussion body is being updated to propose an application-controlled hook instead. Original proposal: Hybrid Lazy Route Discovery (superseded)I'd like to explore starting with lazy route discovery, then loading the full client manifest at a chosen point after startup. This would defer the full manifest beyond the initial document payload while removing ongoing discovery requests once the route tree is complete. The chosen transition timing would determine its impact on startup performance. MotivationLazy Route Discovery primarily improves initial document load by shipping less route metadata for the browser to download and process. This can benefit metrics such as Total Blocking Time and Speed Index, especially for large applications. The tradeoff is additional discovery work on subsequent navigations and fetchers. Param and splat matches need discovery checks because a partial tree may omit a higher-ranking static route. Cached paths avoid repeat requests, and eager discovery often hides the round trip before interaction, but new destinations can still introduce a waterfall. Discovery documentation, matching behavior. A smaller route tree also saves memory and may match faster. My hypothesis is that, for some applications, discovery costs outweigh those benefits after initial load. We should benchmark that tradeoff, particularly as route matching becomes more efficient. Rolling deploymentsAn A client can send a manifest request to a B server, receive a version mismatch, and reload onto another A pod. A subsequent mismatch can then exhaust existing reload recovery. Continuing against the unchanged partial tree is unsafe because an undiscovered route can be shadowed by a catch-all—the issue addressed by #15489. If immutable client artifacts remain available, we could instead load A's full manifest, complete A's route tree, and disable further discovery requests. This requires neither a document refresh nor knowing which deployment is newer. Proposed APIExpand // react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
routeDiscovery: {
mode: "unstable_lazy-until-demand",
},
} satisfies Config;
All Existing modes, the default, and support for custom
|
|
Thanks for putting this proposal together! Very excited that you’re thinking about improving lazy route discovery, and overall this seems like a solid next step to try. Would be very happy to help test changes. I’ll post a few other thoughts and rough ideas as separate comments in case it’s helpful to thread discussion. For context, we have a large framework mode app and could really use this update. Our biggest pain point with lazy mode today is the UX of getting refreshed during client-heavy workflows that require navigation. For example: a user may be working on a form, and then need to open a routed modal on top of that to input more info. If they had the tab open across a time we happen to deploy, they lose their in-progress inputs to the base form when they open the modal. A second issue we see is dynamic paths that end up causing repeated discovery requests. For example: |
Requesting the manifest from Maybe we could achieve the same transforms we need on the client, if there was a callback on this API that gave us access to the manifest data before its routes are patched in? Something like |
|
In discoverRoutes(['/items/*']);or an explicit option if necessary: discoverRoutes({ prefix: '/items' });
The problems I’m thinking about that bring this to mind are: a) not having to maintain a list of every child path Maybe separate problems, but for b, after loading a prefix I was wondering if we could cache that we’ve discovered its full namespace, or otherwise mark that part of the route tree ‘complete’ in order to avoid repeated manifest requests for every |
|
As another option, I wonder whether there’d be a build-time path to segmenting the app into route groups ahead of time, sort of like mini groups of /* /items + all its child routes would be discovered at once */
route('items', './routes/items.id.tsx', { discovery: 'initial' }, [
route(':id', './routes/items.id.tsx'),
route('new', './routes/items.new.tsx'),
]),This could be complementary to the runtime APIs. The behavior would be to have the initial manifest include the full subtree of routes on document load, as well as any time one of the paths within the The main idea here is to keep the current lazy behavior of refreshing after there’s been a deployment, but control the boundaries of when it happens a bit better and avoid having to show reload notifications. I’d see us using something like this to create boundaries around areas of the app we know are full page / contextual transitions. |
|
We're dropping Authored by an AI agent on behalf of @brophdawg11 |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Add
unstable_useRouteDiscovery()so applications can discover selected destinations, complete their client route tree at a chosen time, and control recovery from manifest mismatches. Keep the existingrouteDiscovery.modevalues; applications can implement hybrid strategies through the hook without adding new configuration modes.Motivation
Lazy route discovery reduces the route metadata shipped with the initial document, but introduces discovery requests during eager link discovery and subsequent navigations/fetchers. Applications may prefer to load the full manifest later, preserving lazy startup without paying discovery costs throughout the session.
During rolling deployments, discovery can also encounter a manifest version mismatch. If immutable client assets remain available, loading the client's own full manifest can complete its route tree without a document refresh. Other applications may prefer to preserve drafts or show a reload dialog before recovering.
Proposed API
Initial scope is conventional SSR Framework Mode configured with
routeDiscovery.mode: "lazy". The hook remains usable after discovery becomes complete. Existinglazyandinitialbehavior remains unchanged without opting into the hook's operations or handlers. RSC requires separate evaluation.Full-manifest loading
loadAllRoutes()loads the running client's existingmanifest.url, verifies its version, merges missing metadata into the running manifest, and patches the router. It loads route metadata, not every route module. Only after completion does React Router disable eager and navigation/fetcher discovery for that document.The state moves from
partialtoloadingtocomplete. Concurrent calls share one load; calls after completion resolve immediately. Failure rejects and returns the state topartial. An individual navigation abort must not cancel a shared load needed by other operations.Applications must retain the client's versioned manifest and referenced assets, and keep server routing and loader/action contracts compatible with supported clients. Completing A's route tree does not make B's incompatible backend behavior safe. The client's
versiondoes not change.Selective discovery
discoverRoutes(paths, { signal })fetches and applies metadata for selected destinations without navigating, executing loaders/actions, or prefetching route assets. This incorporates the selective discovery use case in #14816, such as preparing a wizard or command palette without rendering hidden links.Batch requested paths, skip previously discovered paths, and respect the manifest request URL limit without silently dropping requested discovery. If the full tree is already complete, resolve with
successimmediately. Selective discovery leaves the shared statepartial;loadingdescribes full-manifest loading, not individual selective requests.The results distinguish completed discovery (
success), an unresolved mismatch (version-mismatch), and caller cancellation (aborted). Success means metadata discovery completed, not that every requested pathname matches a route. Actual request or handler failures reject.These calls use the same before-discovery and mismatch handler chains with
source: "imperative"andreloadUrl: null. If a handler completes the full tree, resolve withsuccessunless the caller was aborted. Otherwise report an unresolved mismatch. With no mismatch handler, reportversion-mismatchwithout forcing a document reload. Cancellation must not abort a shared full-manifest recovery needed by other operations.Discovery and mismatch handlers
onBeforeDiscoveryruns only when React Router would otherwise make an incremental manifest request, after discovery-cache and request-size checks. React Router awaits the handler chain, then skips the request if the tree is complete; otherwise incremental discovery proceeds.If full-manifest loading initiated by a before-discovery handler fails, fall back to incremental discovery and report the error through the client-side
onErrorcallback where supported. The directloadAllRoutes()promise still rejects. Mismatch recovery failures must never resume an operation against an incomplete tree.onManifestMismatchruns when a discovery response indicates a version mismatch. After awaiting the handler chain:version-mismatchwithout forcing a document reload.A handler can await
loadAllRoutes()to resolve the mismatch, or display a dialog and return to cancel the operation. It can also remain pending while awaiting a user decision. Completing discovery after an operation has been canceled does not replay that navigation or submission. Rejected handler promises stop propagation and use the discovery error path rather than silently continuing.Concurrent mismatch recovery
Concurrent mismatches share one pending handler chain. Its event describes the request that initiated recovery, including that request's
sourceandreloadUrl; these values do not change when other requests join.Each joining operation retains its own destination and abort signal. Aborting or superseding one operation stops its participation without canceling recovery for other operations. After the chain settles, surviving operations rematch and continue if the tree is complete; otherwise they are canceled. Canceled submissions are never replayed.
Once the handler chain settles, a later mismatch may start a new recovery attempt. A handler choosing a manual document reload uses the initiating event's
reloadUrl, rather than automatically tracking destinations from later requests.Composition and temporary overrides
The hook manages registration and cleanup internally. For each event, handlers run in reverse registration order, and each is awaited before the next runs.
stopPropagation()prevents earlier handlers from running; it does not itself complete discovery or authorize continuation.Unmounting removes that component's registration, revealing earlier handlers. Callback updates do not change registration priority. Registrations are removed by identity, so cleanup also works when components unmount out of order or effects are replayed in Strict Mode.
This is registration-order precedence, not a promise of deepest-component-first traversal. Hook calls without handlers simply consume the shared state and operations.
Userland strategies
Keep config at
mode: "lazy". Common hybrid strategies can be expressed through the hook:discovery: complete the tree instead of the first manifest request, including eager or imperative discovery.demand: complete it when any navigation/fetcher first needs discovery, regardless of how the operation was triggered. Preparatory imperative discovery does not trigger completion.mismatch: continue incremental discovery until a mismatch.Every strategy attempts full-manifest recovery on a mismatch. Demand mode may otherwise remain lazy indefinitely if eager discovery covers every operation. Discovery mode can begin loading shortly after hydration and may affect startup metrics such as TBT; it is not an idle scheduling guarantee.
Applications can also invoke the returned
loadAllRoutes()after authentication, during application-defined idle time, or before a workflow. Loading UI derives fromstate === "loading".Preserving application state
A root handler can provide full-manifest recovery. An editor mounted later can temporarily intercept it:
The dialog can save drafts and refresh, or call
loadAllRoutes()and let the user retry the canceled operation.Hydration and implementation
Delay eager discovery until
router.state.initializedis true and the corresponding route tree has committed. Hook registration must be coordinated with that boundary so a root-level handler can intercept the first eager request. This does not wait for every descendant behind Suspense, nor guarantee an idle main thread.Discovery before application handlers can mount is not expected in the intended SSR flow. If it occurs, use normal lazy discovery and existing mismatch recovery rather than waiting for registration.
The existing
useFogOFWarDiscovery()remains the internal eager-discovery driver. It, navigation/fetcher discovery, and imperative discovery would share the hook's runtime for handler dispatch, full-manifest loading, and completion state. Multiple consumers must not create duplicate observers. Completion must also safely account for incremental requests already in flight.Scope and validation
Deployment polling and staleness policy remain application-owned; there is no
checkStalenessAPI. The exposed client version can support an application resource route and polling fetcher. A manifest mismatch establishes differing versions, not which deployment is newer.Validate initial hydration timing, handler ordering and cleanup, concurrent/aborted operations, cancellation of submissions, selective discovery batching and result states, param/splat matching, retained/unavailable assets, and rolling deployments. Measure initial load and first/subsequent operation latency to evaluate the userland strategies.
Authored by an AI agent on behalf of @brophdawg11
All reactions