Skip to content

feat: implement lazy route definitions#139

Open
uhyo wants to merge 1 commit intomasterfrom
claude/implement-lazy-routes-KjNJW
Open

feat: implement lazy route definitions#139
uhyo wants to merge 1 commit intomasterfrom
claude/implement-lazy-routes-KjNJW

Conversation

@uhyo
Copy link
Owner

@uhyo uhyo commented Feb 28, 2026

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

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
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 children can 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-polyfill for 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.

Comment on lines +71 to 74
const { children, hasUnresolvedLazy } = resolveChildren(route);
const hasChildren =
(children !== undefined && children.length > 0) || hasUnresolvedLazy;
const skipLoaders = options?.skipLoaders ?? false;
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +296
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();
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +311 to +315
const voidPromise = result.then(triggerRerender, (error: unknown) => {
triggerRerender();
// Re-throw so the promise stays rejected for use() to catch
throw error;
});
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
},
);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants