-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement lazy route definitions #139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,6 +18,7 @@ import { | |||||||||||||||||||||||||||||||||||||||
| createBlockerRegistry, | ||||||||||||||||||||||||||||||||||||||||
| } from "../context/BlockerContext.js"; | ||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||
| type InternalRouteDefinition, | ||||||||||||||||||||||||||||||||||||||||
| type NavigateOptions, | ||||||||||||||||||||||||||||||||||||||||
| type OnNavigateCallback, | ||||||||||||||||||||||||||||||||||||||||
| type FallbackMode, | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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]); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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). | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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(); | ||||||||||||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||||||||||||
| 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; | |
| }, | |
| ); |
There was a problem hiding this comment.
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).