Conversation
Allow `children` to accept a function that returns route definitions, either synchronously or as a promise. This enables code-splitting of entire route subtrees, loaded on demand when a user navigates to a matching path. Implementation: - Update InternalRouteDefinition and route definition types to accept lazy children functions alongside static arrays - Add resolveChildren() in matchRoutes to handle sync/async function children; async returns partial parent-only match - Add lazyCache state in Router to track in-flight lazy promises and trigger re-matching after resolution - Create PendingOutlet component that uses React.use() to suspend while lazy children load - Update RouteRenderer to render PendingOutlet when a matched route has unresolved lazy children - Export LazyRouteChildren type from public API - Add urlpattern-polyfill for test environment - Add comprehensive test suite (15 tests) covering sync/async resolution, navigation, caching, nesting, loaders, error boundaries, and pathless routes https://claude.ai/code/session_01RwV81q9aZ9QtGzKoHbDFaz
There was a problem hiding this comment.
Pull request overview
Adds support for lazily-defined route children (sync or async) to enable code-splitting of route subtrees, integrating Suspense-based loading at render time.
Changes:
- Expanded route definition types (public + internal) so
childrencan be an array or a lazy function returning routes or a promise. - Updated route matching + Router rendering to handle unresolved async children via a promise cache and a Suspense-suspending outlet (
PendingOutlet). - Added
urlpattern-polyfillfor the test environment and introduced a dedicated lazy-routing test suite.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Locks urlpattern-polyfill addition. |
| packages/router/src/types.ts | Extends internal route definition types to allow lazy children functions. |
| packages/router/src/route.ts | Introduces and wires LazyRouteChildren into public route types. |
| packages/router/src/index.ts | Exports LazyRouteChildren from the public API. |
| packages/router/src/core/matchRoutes.ts | Adds lazy-children handling and partial-match behavior for async children. |
| packages/router/src/context/RouterContext.ts | Adds lazyCache to context for rendering unresolved lazy children. |
| packages/router/src/Router/index.tsx | Adds lazyCache state and async lazy-children resolution logic to trigger re-matching. |
| packages/router/src/Router/RouteRenderer.tsx | Renders PendingOutlet when a matched route has unresolved lazy children. |
| packages/router/src/Router/PendingOutlet.tsx | New component that suspends via React.use(promise). |
| packages/router/src/tests/vitest.setup.ts | Loads urlpattern-polyfill for URLPattern availability in tests. |
| packages/router/src/tests/lazy.test.tsx | New test suite covering lazy children behavior and integration. |
| packages/router/package.json | Adds urlpattern-polyfill to devDependencies. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const { children, hasUnresolvedLazy } = resolveChildren(route); | ||
| const hasChildren = | ||
| (children !== undefined && children.length > 0) || hasUnresolvedLazy; | ||
| const skipLoaders = options?.skipLoaders ?? false; |
There was a problem hiding this comment.
matchRoute() calls resolveChildren(route) before knowing whether the route matches. Since resolveChildren executes lazy children functions, this can eagerly start loading child route subtrees for routes that won’t match (and even during SSR/skipLoaders evaluation). To avoid unnecessary work/side effects, defer calling the children function until after the parent route has matched and you actually need to attempt matching children; for exact-defaulting you can treat function-children as “has children” without executing the function.
| const lazyFn = route.children as () => | ||
| | InternalRouteDefinition[] | ||
| | Promise<InternalRouteDefinition[]>; | ||
| // Call the function — user's cache returns the same Promise that | ||
| // matchRoutes received, so no duplicate loading is triggered. | ||
| const result = lazyFn(); |
There was a problem hiding this comment.
This block calls the lazy children function again (lazyFn()) even though matchRoutes() already invoked it to determine whether children were sync vs Promise. If the function has side effects (e.g., dynamic import/fetch) and doesn’t internally memoize, this can start duplicate loads. Consider changing the flow so the lazy function is invoked in only one place (e.g., have matchRoutes treat function-children as unresolved without calling it, or have matchRoutes return the pending Promise so Router can reuse it).
| const voidPromise = result.then(triggerRerender, (error: unknown) => { | ||
| triggerRerender(); | ||
| // Re-throw so the promise stays rejected for use() to catch | ||
| throw error; | ||
| }); |
There was a problem hiding this comment.
The current approach relies on a second matchRoutes() pass where the user’s lazy children function “returns sync” after the Promise resolves (see comment about function → sync → full match). If a lazy children function always returns a Promise (a very common pattern with import()), the router never stores the resolved children anywhere, so matching will remain partial and the outlet can become permanently empty once the cached promise is resolved. To make this robust, the router should persist the resolved children (or mutate/override children) and have matchRoutes/resolveChildren use that stored result instead of requiring the user to implement a sync cache.
| const voidPromise = result.then(triggerRerender, (error: unknown) => { | |
| triggerRerender(); | |
| // Re-throw so the promise stays rejected for use() to catch | |
| throw error; | |
| }); | |
| const voidPromise = result.then( | |
| (resolvedChildren: InternalRouteDefinition[]) => { | |
| // Persist resolved children on the route so future matches | |
| // can resolve them synchronously, even if the original | |
| // children function always returns a Promise. | |
| route.children = resolvedChildren; | |
| triggerRerender(); | |
| }, | |
| (error: unknown) => { | |
| triggerRerender(); | |
| // Re-throw so the promise stays rejected for use() to catch | |
| throw error; | |
| }, | |
| ); |
Allow
childrento accept a function that returns route definitions,either synchronously or as a promise. This enables code-splitting of
entire route subtrees, loaded on demand when a user navigates to a
matching path.
Implementation:
lazy children functions alongside static arrays
children; async returns partial parent-only match
trigger re-matching after resolution
while lazy children load
has unresolved lazy children
resolution, navigation, caching, nesting, loaders, error
boundaries, and pathless routes
https://claude.ai/code/session_01RwV81q9aZ9QtGzKoHbDFaz