Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,13 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"jsdom": "^28.1.0",
"react": "^19.2.4",
"tsdown": "^0.20.3",
"typescript": "^5.7.0",
"urlpattern-polyfill": "^10.1.0",
"vitest": "^4.0.18"
}
}
17 changes: 17 additions & 0 deletions packages/router/src/Router/PendingOutlet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type ReactNode, use } from "react";

/**
* A component that suspends while lazy children are being resolved.
* Rendered as the outlet when a matched route has unresolved lazy children.
*
* This component is intentionally thin — it only calls use() to suspend.
* Promise creation and caching happen in the Router component.
*/
export function PendingOutlet({
promise,
}: {
promise: Promise<void>;
}): ReactNode {
use(promise);
return null;
}
27 changes: 20 additions & 7 deletions packages/router/src/Router/RouteRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { RouterContext } from "../context/RouterContext.js";
import { RouteContext } from "../context/RouteContext.js";
import type { MatchedRouteWithData, InternalRouteState } from "../types.js";
import { useRouteStateCallbacks } from "./useRouteStateCallbacks.js";
import { PendingOutlet } from "./PendingOutlet.js";

export type RouteRendererProps = {
matchedRoutes: MatchedRouteWithData[];
Expand Down Expand Up @@ -35,6 +36,7 @@ export function RouteRenderer({
isPending,
navigateAsync,
updateCurrentEntryState,
lazyCache,
} = routerContext;

// Extract this route's state from internal structure
Expand All @@ -51,13 +53,24 @@ export function RouteRenderer({
);

// Create outlet for child routes
const outlet = useMemo(
() =>
index < matchedRoutes.length - 1 ? (
<RouteRenderer matchedRoutes={matchedRoutes} index={index + 1} />
) : null,
[matchedRoutes, index],
);
const outlet = useMemo(() => {
if (index < matchedRoutes.length - 1) {
// Existing: child route matched, render it
return <RouteRenderer matchedRoutes={matchedRoutes} index={index + 1} />;
}

// If this route has unresolved lazy children, suspend via PendingOutlet
const currentRoute = matchedRoutes[index]?.route;
if (currentRoute && typeof currentRoute.children === "function") {
const promise = lazyCache.get(currentRoute);
// promise is guaranteed to exist — Router creates it before rendering
if (promise) {
return <PendingOutlet promise={promise} />;
}
}

return null;
}, [matchedRoutes, index, lazyCache]);

// Extract id from route definition (if available)
const routeId = (route as { id?: string }).id;
Expand Down
72 changes: 71 additions & 1 deletion packages/router/src/Router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
createBlockerRegistry,
} from "../context/BlockerContext.js";
import {
type InternalRouteDefinition,
type NavigateOptions,
type OnNavigateCallback,
type FallbackMode,
Expand Down Expand Up @@ -107,6 +108,19 @@ export function Router({
}: RouterProps): ReactNode {
const routes = internalRoutes(inputRoutes);

// Cache of in-flight lazy resolution promises.
// Identity change (new Map reference) triggers matchedRoutesWithData recomputation.
const [lazyCache, setLazyCache] = useState(
() => new Map<InternalRouteDefinition, Promise<void>>(),
);

// Clear cache when routes prop changes (new route definitions)
const [prevRoutes, setPrevRoutes] = useState(inputRoutes);
if (prevRoutes !== inputRoutes) {
setPrevRoutes(inputRoutes);
setLazyCache(new Map());
}

// Create adapter once based on browser capabilities and fallback setting
const adapter = useMemo(() => createAdapter(fallback), [fallback]);

Expand Down Expand Up @@ -229,6 +243,11 @@ export function Router({

// Match routes and execute loaders
const matchedRoutesWithData = useMemo(() => {
// lazyCache identity change triggers recomputation after lazy children resolve.
// matchRoutes calls lazy functions internally — after resolution, the user's
// cache returns sync, producing a full match.
void lazyCache;

if (!runLoaders) {
// SSR/hydration without loader execution: match routes, data is undefined.
// Routes with loaders are skipped (skipLoaders: true).
Expand All @@ -252,7 +271,56 @@ export function Router({
const request = createLoaderRequest(urlObject);
const signal = adapter.getIdleAbortSignal();
return executeLoaders(matched, entryKey, request, signal);
}, [routes, adapter, urlObject, runLoaders, locationKey]);
}, [routes, adapter, urlObject, runLoaders, locationKey, lazyCache]);

// --- Lazy children resolution (async case) ---
// If matchRoutes returned a partial match (lazy function returned a Promise),
// the deepest matched route still has typeof children === 'function'.
// Call the function to get the Promise (same one matchRoutes got — user caches it),
// register it in lazyCache, and attach a .then() handler.
// This is setState-during-render on Router's OWN state, so React correctly
// discards the current render and re-renders with the updated cache.
if (matchedRoutesWithData) {
const lastMatch = matchedRoutesWithData[matchedRoutesWithData.length - 1];
if (
lastMatch &&
typeof lastMatch.route.children === "function" &&
!lazyCache.has(lastMatch.route)
) {
const route = lastMatch.route;
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();
Comment on lines +291 to +296
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.
if (!Array.isArray(result)) {
// Async result — register promise and attach .then() handler
const triggerRerender = () => {
// Trigger Router re-render for the INITIAL PAGE LOAD case.
// During navigation, startTransition already retries the entire
// transition (including Router) when the promise resolves, so
// this is redundant. But on initial page load there is no
// transition — only the Suspense subtree retries. Router is above
// the Suspense boundary and won't re-render on its own. Without
// this, PendingOutlet would return null (promise resolved) and the
// outlet would be empty. This state update triggers Router to
// re-run matchRoutes, which calls the function → sync → full match.
setLazyCache((prev) => new Map(prev));
};
const voidPromise = result.then(triggerRerender, (error: unknown) => {
triggerRerender();
// Re-throw so the promise stays rejected for use() to catch
throw error;
});
Comment on lines +311 to +315
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.
// Register promise in cache. setState-during-render on own state:
// React discards this render and immediately re-renders with the
// updated cache. On re-render, the promise is found, RouteRenderer
// passes it to PendingOutlet, and use() suspends.
setLazyCache((prev) => new Map([...prev, [route, voidPromise]]));
}
}
}

const locationState = locationEntry?.state;
const locationInfo = locationEntry?.info;
Expand All @@ -264,6 +332,7 @@ export function Router({
isPending,
navigateAsync,
updateCurrentEntryState,
lazyCache,
}),
[
locationState,
Expand All @@ -272,6 +341,7 @@ export function Router({
isPending,
navigateAsync,
updateCurrentEntryState,
lazyCache,
],
);

Expand Down
Loading
Loading