From 566672b4a9010e0d1cd29e3a28d9d3de46412d57 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Feb 2023 12:29:38 -0500 Subject: [PATCH 01/64] POC of lazily loaded route modules --- packages/router/__tests__/router-test.ts | 37 ++++++++++++++++++ packages/router/router.ts | 50 +++++++++++++++++++++++- packages/router/utils.ts | 27 ++++++++++--- 3 files changed, 107 insertions(+), 7 deletions(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index e9b19cfc88..2d0b356102 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -11448,6 +11448,43 @@ describe("a router", () => { }); }); + describe("lazily loaded route modules", () => { + it("fetches lazy route modules on navigation", async () => { + let router = createRouter({ + routes: [ + { + path: "/", + }, + { + id: "lazy", + path: "/lazy", + lazy: async () => { + await new Promise((r) => setTimeout(r, 100)); + return { + async loader() { + await new Promise((r) => setTimeout(r, 100)); + return "LAZY"; + }, + }; + }, + }, + ], + history: createMemoryHistory(), + }).initialize(); + + router.navigate("/lazy"); + expect(router.state.location.pathname).toBe("/"); + expect(router.state.navigation.state).toBe("loading"); + await new Promise((r) => setTimeout(r, 250)); + expect(router.state.location.pathname).toBe("/lazy"); + expect(router.state.navigation.state).toBe("idle"); + expect(router.state.loaderData).toEqual({ + lazy: "LAZY", + }); + router.dispose(); + }); + }); + describe("ssr", () => { const SSR_ROUTES = [ { diff --git a/packages/router/router.ts b/packages/router/router.ts index 1f56b629e1..4de920ea4b 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -22,6 +22,7 @@ import type { AgnosticRouteMatch, MutationFormMethod, ShouldRevalidateFunction, + RouteManifest, } from "./utils"; import { DeferredData, @@ -641,7 +642,10 @@ export function createRouter(init: RouterInit): Router { "You must provide a non-empty routes array to createRouter" ); - let dataRoutes = convertRoutesToDataRoutes(init.routes); + // Routes keyed by ID + let manifest: RouteManifest = {}; + // Routes in tree format for matching + let dataRoutes = convertRoutesToDataRoutes(init.routes, undefined, manifest); // Cleanup function for history let unlistenHistory: (() => void) | null = null; // Externally-provided functions to call on all state changes @@ -1426,6 +1430,11 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(rf.key, pendingNavigationController!) ); + let lazyMatches = matchesToLoad.filter((m) => m.route.lazy); + if (lazyMatches.length > 0) { + await loadLazyRouteModules(request, lazyMatches); + } + let { results, loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( state.matches, @@ -1981,6 +1990,41 @@ export function createRouter(init: RouterInit): Router { } } + /** + * Execute route.lazy() methods to lazily load route modules (loader, action, + * shouldRevalidate) and update the routeManifest in place which shares objects + * with dataRoutes so those get updated as well. + */ + async function loadLazyRouteModules( + request: Request, + lazyMatches: AgnosticDataRouteMatch[] + ) { + await Promise.all( + lazyMatches.map(async (match) => { + let mod = await match.route.lazy!(); + if (request.signal.aborted) { + return; + } + + let routeToUpdate = manifest[match.route.id]; + invariant(routeToUpdate, "No route found in manifest"); + + routeToUpdate.lazy = undefined; + + routeToUpdate.loader = + routeToUpdate.loader || mod.loader || (() => null); + + routeToUpdate.action = + routeToUpdate.action || mod.action || (() => null); + + routeToUpdate.shouldRevalidate = + routeToUpdate.shouldRevalidate || + mod.shouldRevalidate || + (({ defaultShouldRevalidate }) => defaultShouldRevalidate); + }) + ); + } + async function callLoadersAndMaybeResolveData( currentMatches: AgnosticDataRouteMatch[], matches: AgnosticDataRouteMatch[], @@ -2923,6 +2967,10 @@ function getMatchesToLoad( let boundaryMatches = getLoaderMatchesUntilBoundary(matches, boundaryId); let navigationMatches = boundaryMatches.filter((match, index) => { + if (typeof match.route.lazy === "function") { + // We haven't loaded this route yet so we don't know if it's got a loader! + return true; + } if (match.route.loader == null) { return false; } diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 172d52c36a..c429472680 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -151,6 +151,11 @@ type AgnosticBaseRouteObject = { hasErrorBoundary?: boolean; shouldRevalidate?: ShouldRevalidateFunction; handle?: any; + lazy?: () => Promise<{ + loader?: AgnosticBaseRouteObject["loader"]; + action?: AgnosticBaseRouteObject["action"]; + shouldRevalidate?: AgnosticBaseRouteObject["shouldRevalidate"]; + }>; }; /** @@ -193,6 +198,8 @@ export type AgnosticDataRouteObject = | AgnosticDataIndexRouteObject | AgnosticDataNonIndexRouteObject; +export type RouteManifest = Record; + // Recursive helper for finding path parameters in the absence of wildcards type _PathParam = // split path into individual path segments @@ -278,7 +285,7 @@ function isIndexRoute( export function convertRoutesToDataRoutes( routes: AgnosticRouteObject[], parentPath: number[] = [], - allIds: Set = new Set() + manifest: RouteManifest = {} ): AgnosticDataRouteObject[] { return routes.map((route, index) => { let treePath = [...parentPath, index]; @@ -288,23 +295,31 @@ export function convertRoutesToDataRoutes( `Cannot specify children on an index route` ); invariant( - !allIds.has(id), + !manifest[id], `Found a route id collision on id "${id}". Route ` + "id's must be globally unique within Data Router usages" ); - allIds.add(id); if (isIndexRoute(route)) { let indexRoute: AgnosticDataIndexRouteObject = { ...route, id }; + manifest[id] = indexRoute; return indexRoute; } else { let pathOrLayoutRoute: AgnosticDataNonIndexRouteObject = { ...route, id, - children: route.children - ? convertRoutesToDataRoutes(route.children, treePath, allIds) - : undefined, + children: undefined, }; + manifest[id] = pathOrLayoutRoute; + + if (route.children) { + pathOrLayoutRoute.children = convertRoutesToDataRoutes( + route.children, + treePath, + manifest + ); + } + return pathOrLayoutRoute; } }); From 36975ffdb554c8dafd96b1d3f48734cbdf5bf61a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 3 Feb 2023 12:46:42 -0500 Subject: [PATCH 02/64] Update logic and add Marks examples --- .changeset/many-frogs-accept.md | 7 + examples/data-router/src/app.tsx | 63 ++++++ examples/data-router/src/lazy.tsx | 70 +++++++ examples/ssr-data-router/package-lock.json | 50 ++--- examples/ssr-data-router/package.json | 4 +- examples/ssr-data-router/src/App.tsx | 33 +++- examples/ssr-data-router/src/entry.client.tsx | 34 +++- examples/ssr-data-router/src/entry.server.tsx | 4 +- examples/ssr-data-router/src/lazy.tsx | 70 +++++++ packages/react-router-dom/server.tsx | 38 +--- packages/react-router/lib/components.tsx | 1 + packages/react-router/lib/context.ts | 18 ++ packages/router/index.ts | 1 + packages/router/router.ts | 182 +++++++++++++----- packages/router/utils.ts | 2 + 15 files changed, 471 insertions(+), 106 deletions(-) create mode 100644 .changeset/many-frogs-accept.md create mode 100644 examples/data-router/src/lazy.tsx create mode 100644 examples/ssr-data-router/src/lazy.tsx diff --git a/.changeset/many-frogs-accept.md b/.changeset/many-frogs-accept.md new file mode 100644 index 0000000000..9e279f0bf8 --- /dev/null +++ b/.changeset/many-frogs-accept.md @@ -0,0 +1,7 @@ +--- +"react-router": minor +"react-router-dom": minor +"@remix-run/router": minor +--- + +Add support for `route.lazy` for code-splitting and lazy-loading of route modules diff --git a/examples/data-router/src/app.tsx b/examples/data-router/src/app.tsx index d6e9a89534..185adbd104 100644 --- a/examples/data-router/src/app.tsx +++ b/examples/data-router/src/app.tsx @@ -44,10 +44,40 @@ let router = createBrowserRouter( loader={deferredLoader} element={} /> + { + console.log("loading lazy"); + await sleep(1000); + console.log("done loading lazy"); + let { + default: Component, + loader, + action, + ErrorBoundary, + shouldRevalidate, + } = await import("./lazy"); + + return { + element: , + loader, + action, + shouldRevalidate, + ...(ErrorBoundary + ? { + errorElement: , + hasErrorBoundary: true, + } + : {}), + }; + }} + /> ) ); +window.router = router; + if (import.meta.hot) { import.meta.hot.dispose(() => router.dispose()); } @@ -68,6 +98,7 @@ export function Fallback() { export function Layout() { let navigation = useNavigation(); let revalidator = useRevalidator(); + let lazyFetcher = useFetcher(); let fetchers = useFetchers(); let fetcherInProgress = fetchers.some((f) => ["loading", "submitting"].includes(f.state) @@ -94,14 +125,46 @@ export function Layout() {
  • Deferred
  • +
  • + Lazy +
  • {" "}
  • 404 Link
  • +
  • +
    + +
    +
  • +
  • + +    + + fetcher state/data: {lazyFetcher.state}/ + {JSON.stringify(lazyFetcher.data)} + +
  • +
  • + +    + + fetcher state/data: {lazyFetcher.state}/ + {JSON.stringify(lazyFetcher.data)} + +
  • diff --git a/examples/data-router/src/lazy.tsx b/examples/data-router/src/lazy.tsx new file mode 100644 index 0000000000..aed2c061be --- /dev/null +++ b/examples/data-router/src/lazy.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import type { + ActionFunction, + ShouldRevalidateFunction, +} from "react-router-dom"; +import { Form, useLoaderData } from "react-router-dom"; + +interface LazyLoaderData { + date: string; + submissionCount: number; +} + +let submissionCount = 0; + +export const loader = async (): Promise => { + console.log("lazy loader start"); + await new Promise((r) => setTimeout(r, 1000)); + console.log("lazy loader end"); + return { + date: new Date().toISOString(), + submissionCount, + }; +}; + +export const action: ActionFunction = async ({ request }) => { + console.log("lazy action start"); + await new Promise((r) => setTimeout(r, 1000)); + console.log("lazy action end"); + + let body = await request.formData(); + if (body.get("error")) { + throw new Error("Form action error"); + } + + submissionCount++; + return submissionCount; +}; + +export function ErrorBoundary() { + return ( + <> +

    Lazy error boundary

    +
    Something went wrong
    + + ); +} + +export const shouldRevalidate: ShouldRevalidateFunction = (args) => { + return Boolean(args.formAction); +}; + +export default function LazyPage() { + let data = useLoaderData() as LazyLoaderData; + + return ( + <> +

    Lazy

    +

    Date from loader: {data.date}

    +

    Form submission count: {data.submissionCount}

    +
    +
    + + +
    +
    + + ); +} diff --git a/examples/ssr-data-router/package-lock.json b/examples/ssr-data-router/package-lock.json index b897cbb3ab..89afb66dce 100644 --- a/examples/ssr-data-router/package-lock.json +++ b/examples/ssr-data-router/package-lock.json @@ -7,14 +7,14 @@ "name": "ssr-data-router", "dependencies": { "@remix-run/node": "^1.7.0", - "@remix-run/router": "^1.0.0", + "@remix-run/router": "^1.3.1", "compression": "1.7.4", "cross-env": "^7.0.3", "express": "^4.17.1", "history": "^5.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.4.0" + "react-router-dom": "^6.8.0" }, "devDependencies": { "@rollup/plugin-replace": "^3.0.0", @@ -486,9 +486,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.0.tgz", - "integrity": "sha512-SCR1cxRSMNKjaVYptCzBApPDqGwa3FGdjVHc+rOToocNPHQdIYLZBfv/3f+KvYuXDkUGVIW9IAzmPNZDRL1I4A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.1.tgz", + "integrity": "sha512-+eun1Wtf72RNRSqgU7qM2AMX/oHp+dnx7BHk1qhK5ZHzdHTUU4LA1mGG1vT+jMc8sbhG3orvsfOmryjzx2PzQw==", "engines": { "node": ">=14" } @@ -2365,11 +2365,11 @@ } }, "node_modules/react-router": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.0.tgz", - "integrity": "sha512-B+5bEXFlgR1XUdHYR6P94g299SjrfCBMmEDJNcFbpAyRH1j1748yt9NdDhW3++nw1lk3zQJ6aOO66zUx3KlTZg==", + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.0.tgz", + "integrity": "sha512-760bk7y3QwabduExtudhWbd88IBbuD1YfwzpuDUAlJUJ7laIIcqhMvdhSVh1Fur1PE8cGl84L0dxhR3/gvHF7A==", "dependencies": { - "@remix-run/router": "1.0.0" + "@remix-run/router": "1.3.1" }, "engines": { "node": ">=14" @@ -2379,11 +2379,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.0.tgz", - "integrity": "sha512-4Aw1xmXKeleYYQ3x0Lcl2undHR6yMjXZjd9DKZd53SGOYqirrUThyUb0wwAX5VZAyvSuzjNJmZlJ3rR9+/vzqg==", + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.8.0.tgz", + "integrity": "sha512-hQouduSTywGJndE86CXJ2h7YEy4HYC6C/uh19etM+79FfQ6cFFFHnHyDlzO4Pq0eBUI96E4qVE5yUjA00yJZGQ==", "dependencies": { - "react-router": "6.4.0" + "@remix-run/router": "1.3.1", + "react-router": "6.8.0" }, "engines": { "node": ">=14" @@ -3194,9 +3195,9 @@ } }, "@remix-run/router": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.0.tgz", - "integrity": "sha512-SCR1cxRSMNKjaVYptCzBApPDqGwa3FGdjVHc+rOToocNPHQdIYLZBfv/3f+KvYuXDkUGVIW9IAzmPNZDRL1I4A==" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.1.tgz", + "integrity": "sha512-+eun1Wtf72RNRSqgU7qM2AMX/oHp+dnx7BHk1qhK5ZHzdHTUU4LA1mGG1vT+jMc8sbhG3orvsfOmryjzx2PzQw==" }, "@remix-run/server-runtime": { "version": "1.7.0", @@ -4566,19 +4567,20 @@ "dev": true }, "react-router": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.0.tgz", - "integrity": "sha512-B+5bEXFlgR1XUdHYR6P94g299SjrfCBMmEDJNcFbpAyRH1j1748yt9NdDhW3++nw1lk3zQJ6aOO66zUx3KlTZg==", + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.0.tgz", + "integrity": "sha512-760bk7y3QwabduExtudhWbd88IBbuD1YfwzpuDUAlJUJ7laIIcqhMvdhSVh1Fur1PE8cGl84L0dxhR3/gvHF7A==", "requires": { - "@remix-run/router": "1.0.0" + "@remix-run/router": "1.3.1" } }, "react-router-dom": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.0.tgz", - "integrity": "sha512-4Aw1xmXKeleYYQ3x0Lcl2undHR6yMjXZjd9DKZd53SGOYqirrUThyUb0wwAX5VZAyvSuzjNJmZlJ3rR9+/vzqg==", + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.8.0.tgz", + "integrity": "sha512-hQouduSTywGJndE86CXJ2h7YEy4HYC6C/uh19etM+79FfQ6cFFFHnHyDlzO4Pq0eBUI96E4qVE5yUjA00yJZGQ==", "requires": { - "react-router": "6.4.0" + "@remix-run/router": "1.3.1", + "react-router": "6.8.0" } }, "regenerator-runtime": { diff --git a/examples/ssr-data-router/package.json b/examples/ssr-data-router/package.json index 66ed58552a..e039190785 100644 --- a/examples/ssr-data-router/package.json +++ b/examples/ssr-data-router/package.json @@ -11,14 +11,14 @@ }, "dependencies": { "@remix-run/node": "^1.7.0", - "@remix-run/router": "^1.0.0", + "@remix-run/router": "^1.3.1", "compression": "1.7.4", "cross-env": "^7.0.3", "express": "^4.17.1", "history": "^5.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.4.0" + "react-router-dom": "^6.8.0" }, "devDependencies": { "@rollup/plugin-replace": "^3.0.0", diff --git a/examples/ssr-data-router/src/App.tsx b/examples/ssr-data-router/src/App.tsx index 9ccd40bba8..3c98580c05 100644 --- a/examples/ssr-data-router/src/App.tsx +++ b/examples/ssr-data-router/src/App.tsx @@ -20,6 +20,34 @@ export const routes = [ loader: dashboardLoader, element: , }, + { + path: "lazy", + async lazy() { + console.log("start lazy()"); + await sleep(1000); + console.log("end lazy()"); + let { + default: Component, + loader, + action, + ErrorBoundary, + shouldRevalidate, + } = await import("./lazy"); + + return { + element: , + loader, + action, + shouldRevalidate, + ...(ErrorBoundary + ? { + errorElement: , + hasErrorBoundary: true, + } + : {}), + }; + }, + }, { path: "redirect", loader: redirectLoader, @@ -70,6 +98,9 @@ function Layout() {
  • Dashboard
  • +
  • + Lazy +
  • Redirect to Home
  • @@ -86,7 +117,7 @@ function Layout() { ); } -const sleep = () => new Promise((r) => setTimeout(r, 500)); +const sleep = (n = 500) => new Promise((r) => setTimeout(r, n)); const rand = () => Math.round(Math.random() * 100); async function homeLoader() { diff --git a/examples/ssr-data-router/src/entry.client.tsx b/examples/ssr-data-router/src/entry.client.tsx index 5a2f2ca2c1..6d107b0fe4 100644 --- a/examples/ssr-data-router/src/entry.client.tsx +++ b/examples/ssr-data-router/src/entry.client.tsx @@ -6,9 +6,31 @@ import { routes } from "./App"; let router = createBrowserRouter(routes); -ReactDOM.hydrateRoot( - document.getElementById("app"), - - - -); +// If you're using lazy route modules and you haven't yet preloaded them onto +// routes, then you'll need to wait for the router to be initialized before +// hydrating, since it will have initial data to hydrate but it won't yet have +// any router elements to render. +// +// This shouldn't be needed in most SSR stacks as you should know what routes +// are initially rendered and be able to SSR the appropriate ` + ); + }); + it("serializes ErrorResponse instances", async () => { let routes = [ { @@ -318,6 +524,57 @@ describe("A ", () => { ); }); + it("serializes ErrorResponse instances from lazy routes", async () => { + let routes = [ + { + path: "/", + lazy: async () => ({ + loader: () => { + throw json( + { not: "found" }, + { status: 404, statusText: "Not Found" } + ); + }, + }), + }, + ]; + let { query, dataRoutes } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + + let expectedJsonString = JSON.stringify( + JSON.stringify({ + loaderData: {}, + actionData: null, + errors: { + "0": { + status: 404, + statusText: "Not Found", + internal: false, + data: { not: "found" }, + __type: "RouteErrorResponse", + }, + }, + }) + ); + expect(html).toMatch( + `` + ); + }); + it("serializes Error instances", async () => { let routes = [ { @@ -362,6 +619,52 @@ describe("A ", () => { ); }); + it("serializes Error instances from lazy routes", async () => { + let routes = [ + { + path: "/", + lazy: async () => ({ + loader: () => { + throw new Error("oh no"); + }, + }), + }, + ]; + let { query, dataRoutes } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + + // stack is stripped by default from SSR errors + let expectedJsonString = JSON.stringify( + JSON.stringify({ + loaderData: {}, + actionData: null, + errors: { + "0": { + message: "oh no", + __type: "Error", + }, + }, + }) + ); + expect(html).toMatch( + `` + ); + }); + it("supports a nonce prop", async () => { let routes = [ { @@ -647,6 +950,46 @@ describe("A ", () => { expect(context._deepestRenderedBoundaryId).toBe("0-0"); }); + it("tracks the deepest boundary during render with lazy routes", async () => { + let routes = [ + { + path: "/", + lazy: async () => ({ + element: , + errorElement:

    Error

    , + }), + children: [ + { + index: true, + lazy: async () => ({ + element:

    👋

    , + errorElement:

    Error

    , + }), + }, + ], + }, + ]; + + let { query, dataRoutes } = createStaticHandler(routes); + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toMatchInlineSnapshot(`"

    👋

    "`); + expect(context._deepestRenderedBoundaryId).toBe("0-0"); + }); + it("tracks only boundaries that expose an errorElement", async () => { let routes = [ { @@ -680,5 +1023,42 @@ describe("A ", () => { expect(html).toMatchInlineSnapshot(`"

    👋

    "`); expect(context._deepestRenderedBoundaryId).toBe("0"); }); + + it("tracks only boundaries that expose an errorElement with lazy routes", async () => { + let routes = [ + { + path: "/", + lazy: async () => ({ + element: , + errorElement:

    Error

    , + }), + children: [ + { + index: true, + element:

    👋

    , + }, + ], + }, + ]; + + let { query, dataRoutes } = createStaticHandler(routes); + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toMatchInlineSnapshot(`"

    👋

    "`); + expect(context._deepestRenderedBoundaryId).toBe("0"); + }); }); }); diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 7f2e0d26f9..47972a4c30 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -202,7 +202,6 @@ export function createBrowserRouter( basename?: string; hydrationData?: HydrationState; window?: Window; - onInitialize?: (args: { router: RemixRouter }) => void; } ): RemixRouter { return createRouter({ @@ -211,7 +210,6 @@ export function createBrowserRouter( hydrationData: opts?.hydrationData || parseHydrationData(), routes, hasErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), - onInitialize: opts?.onInitialize, }).initialize(); } @@ -221,7 +219,6 @@ export function createHashRouter( basename?: string; hydrationData?: HydrationState; window?: Window; - onInitialize?: (args: { router: RemixRouter }) => void; } ): RemixRouter { return createRouter({ @@ -230,7 +227,6 @@ export function createHashRouter( hydrationData: opts?.hydrationData || parseHydrationData(), routes, hasErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), - onInitialize: opts?.onInitialize, }).initialize(); } diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 016de1ad1b..4ce3b3fe3b 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -67,6 +67,8 @@ export function StaticRouter({ ); } +export { StaticHandlerContext }; + export interface StaticRouterProviderProps { context: StaticHandlerContext; router: RemixRouter; @@ -273,6 +275,9 @@ export function createStaticRouter( initialize() { throw msg("initialize"); }, + ready() { + throw msg("ready"); + }, subscribe() { throw msg("subscribe"); }, diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index c58afc09b2..3f0aef8bfe 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -212,7 +212,6 @@ export function createMemoryRouter( hydrationData?: HydrationState; initialEntries?: InitialEntry[]; initialIndex?: number; - onInitialize?: (args: { router: RemixRouter }) => void; } ): RemixRouter { return createRouter({ @@ -224,7 +223,6 @@ export function createMemoryRouter( hydrationData: opts?.hydrationData, routes, hasErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), - onInitialize: opts?.onInitialize, }).initialize(); } diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index 6e7b954002..e7a62047e3 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -28,7 +28,7 @@ export interface IndexRouteObject { children?: undefined; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; - lazy?: LazyRouteFunction; + lazy?: LazyRouteFunction; } export interface NonIndexRouteObject { @@ -44,7 +44,7 @@ export interface NonIndexRouteObject { children?: RouteObject[]; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; - lazy?: LazyRouteFunction; + lazy?: LazyRouteFunction; } export type RouteObject = IndexRouteObject | NonIndexRouteObject; diff --git a/packages/router/router.ts b/packages/router/router.ts index 1165a79786..1e765d242e 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -82,6 +82,18 @@ export interface Router { */ initialize(): Router; + /** + * Returns a promise that resolves when the router has been initialized + * including any lazy-loaded route properties. This is useful on the client + * after server-side rendering to ensure that the routes are ready to render + * since all elements and error boundaries have been resolved. + * + * TODO: Rename this and/or initialize()? If we make initialize() async then + * the public router creation functions will become async too which is a + * breaking change. + */ + ready(): Promise; + /** * @internal * PRIVATE - DO NOT USE @@ -323,7 +335,6 @@ export interface RouterInit { history: History; hydrationData?: HydrationState; hasErrorBoundary?: HasErrorBoundaryFunction; - onInitialize?: (args: { router: Router }) => void; } /** @@ -849,25 +860,6 @@ export function createRouter(init: RouterInit): Router { } ); - if (init.onInitialize) { - if (state.initialized) { - // We delay calling the onInitialize function until the next tick so - // this function has a chance to return the router instance, otherwise - // consumers will get an error if they try to use the returned router - // instance in their callback. Note that we also provide the router - // instance to the callback as a convenience and to avoid this ambiguity - // in the consumer code. - Promise.resolve().then(() => init.onInitialize!({ router })); - } else { - let unsubscribe = subscribe((updatedState) => { - if (updatedState.initialized) { - unsubscribe(); - init.onInitialize!({ router }); - } - }); - } - } - if (state.initialized) { return router; } @@ -897,6 +889,32 @@ export function createRouter(init: RouterInit): Router { return router; } + // Returns a promise that resolves when the router has been initialized + // including any lazy-loaded route properties. This is useful on the client + // after server-side rendering to ensure that the routes are ready to render + // since all elements and error boundaries have been resolved. + // + // Implemented as a Fluent API for ease of: let router = await + // createRouter(init).initialize().ready(); + // + // TODO: Rename this and/or initialize()? If we make initialize() async then + // the public router creation functions will become async too which is a + // breaking change. + function ready(): Promise { + return new Promise((resolve) => { + if (state.initialized) { + resolve(router); + } else { + let unsubscribe = subscribe((updatedState) => { + if (updatedState.initialized) { + unsubscribe(); + resolve(router); + } + }); + } + }); + } + // Clean up a router and it's side effects function dispose() { if (unlistenHistory) { @@ -2382,6 +2400,7 @@ export function createRouter(init: RouterInit): Router { return dataRoutes; }, initialize, + ready, subscribe, enableScrollRestoration, navigate, @@ -3202,12 +3221,10 @@ async function loadLazyRouteModules( staticRouteValue !== undefined && lazyRouteProperty !== "hasErrorBoundary"; // This property isn't static since it should always be updated based on the route updates - if (__DEV__) { - warning( - !isPropertyStaticallyDefined, - `Route "${routeToUpdate.id}" has a static property "${lazyRouteProperty}" defined but its lazy function is also returning a value for this property. The lazy route property "${lazyRouteProperty}" will be ignored.` - ); - } + warning( + !isPropertyStaticallyDefined, + `Route "${routeToUpdate.id}" has a static property "${lazyRouteProperty}" defined but its lazy function is also returning a value for this property. The lazy route property "${lazyRouteProperty}" will be ignored.` + ); if ( !isPropertyStaticallyDefined && diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 3efc61805d..5b6f683d65 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -153,6 +153,7 @@ export interface HasErrorBoundaryFunction { * ignored. */ export type ImmutableRouteKey = + | "lazy" | "caseSensitive" | "path" | "id" @@ -160,6 +161,7 @@ export type ImmutableRouteKey = | "children"; export const immutableRouteKeys = new Set([ + "lazy", "caseSensitive", "path", "id", From 890cab340e47c5205cfd54739827c62346a56a2a Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 16 Feb 2023 12:03:50 +1100 Subject: [PATCH 33/64] WIP lazy load error handling --- packages/react-router/lib/hooks.tsx | 5 +- packages/router/__tests__/router-test.ts | 152 +++++++++++++++++++++- packages/router/router.ts | 153 ++++++++++++++++++++--- 3 files changed, 284 insertions(+), 26 deletions(-) diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index f766c26e83..c7451a11b0 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -392,7 +392,10 @@ export function useRoutes( warning( matches == null || - matches[matches.length - 1].route.element !== undefined, + matches[matches.length - 1].route.element !== undefined || + // If the route is lazy and has failed to resolve, which means the lazy + // function is still present, it's possible to not have an element. + matches[matches.length - 1].route.lazy !== undefined, `Matched leaf route at location "${location.pathname}${location.search}${location.hash}" does not have an element. ` + `This means it will render an with a null value by default resulting in an "empty" page.` ); diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 7379be9fd5..00b2a40d5a 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -11572,6 +11572,23 @@ describe("a router", () => { }); }); + it("handles errors when failing to load lazy route modules on loading navigation", async () => { + let t = setup({ routes: LAZY_ROUTES }); + + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + + await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + + expect(t.router.state.loaderData).toEqual({}); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); + }); + it("keeps existing loader on loading navigation if static loader is already defined", async () => { let consoleWarn = jest.spyOn(console, "warn"); let t = setup({ @@ -11665,6 +11682,29 @@ describe("a router", () => { }); }); + it("handles bubbling of loader errors in lazy route modules on loading navigation when hasErrorBoundary is not defined", async () => { + let t = setup({ routes: LAZY_ROUTES }); + + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + + let dfd = createDeferred(); + A.lazy.lazy.resolve({ + loader: () => dfd.promise, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + + await dfd.reject(new Error("LAZY LOADER ERROR")); + + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); + }); + it("handles bubbling of loader errors in lazy route modules on loading navigation when hasErrorBoundary is resolved as false", async () => { let t = setup({ routes: LAZY_ROUTES }); @@ -11727,6 +11767,27 @@ describe("a router", () => { }); }); + it("handles errors when failing to load lazy route modules on submission navigation", async () => { + let t = setup({ routes: LAZY_ROUTES }); + + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.loaderData).toEqual({}); + }); + it("keeps existing action on submission navigation if static action is already defined", async () => { let consoleWarn = jest.spyOn(console, "warn"); let t = setup({ @@ -12009,6 +12070,20 @@ describe("a router", () => { expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER"); }); + it("handles errors when failing to load lazy route modules on fetcher.load", async () => { + let t = setup({ routes: LAZY_ROUTES }); + + let key = "key"; + let A = await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); + }); + it("fetches lazy route modules on fetcher.load and stores the first resolved value if fetcher is called multiple times", async () => { let t = setup({ routes: LAZY_ROUTES }); @@ -12043,7 +12118,7 @@ describe("a router", () => { expect(lazyLoaderStubB).toHaveBeenCalledTimes(1); }); - it("handles errors in lazy route modules on fetcher.load", async () => { + it("handles loader errors in lazy route modules on fetcher.load", async () => { let t = setup({ routes: LAZY_ROUTES }); let key = "key"; @@ -12084,6 +12159,23 @@ describe("a router", () => { expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY ACTION"); }); + it("handles errors when failing to load lazy route modules on fetcher.submit", async () => { + let t = setup({ routes: LAZY_ROUTES }); + + let key = "key"; + let A = await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + + await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); + }); + it("fetches lazy route modules and allows action to run on fetcher.submit and stores the first resolved value if fetcher is called multiple times", async () => { let t = setup({ routes: LAZY_ROUTES }); @@ -12125,7 +12217,7 @@ describe("a router", () => { expect(lazyActionStubB).toHaveBeenCalledTimes(2); }); - it("handles errors in lazy route modules on fetcher.submit", async () => { + it("handles action errors in lazy route modules on fetcher.submit", async () => { let t = setup({ routes: LAZY_ROUTES }); let key = "key"; @@ -12173,6 +12265,33 @@ describe("a router", () => { expect(context.loaderData).toEqual({ lazy: { value: "LAZY LOADER" } }); }); + it("throws when failing to load lazy route modules on staticHandler.query()", async () => { + let { query } = createStaticHandler([ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "/lazy", + lazy: async () => { + throw new Error("LAZY FUNCTION ERROR"); + }, + }, + ], + }, + ]); + + let err; + try { + await query(createRequest("/lazy")); + } catch (_err) { + err = _err; + } + + expect(err?.message).toBe("LAZY FUNCTION ERROR"); + }); + it("keeps existing loader when using staticHandler.query() if static loader is already defined", async () => { let consoleWarn = jest.spyOn(console, "warn"); let lazyLoaderStub = jest.fn(async () => { @@ -12305,6 +12424,27 @@ describe("a router", () => { expect(data).toEqual({ value: "LAZY LOADER" }); }); + it("throws when failing to load lazy route modules on staticHandler.queryRoute()", async () => { + let { queryRoute } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + lazy: async () => { + throw new Error("LAZY FUNCTION ERROR"); + }, + }, + ]); + + let err; + try { + await queryRoute(createRequest("/lazy")); + } catch (_err) { + err = _err; + } + + expect(err?.message).toBe("LAZY FUNCTION ERROR"); + }); + it("keeps existing loader when using staticHandler.queryRoute() if static loader is already defined ", async () => { let consoleWarn = jest.spyOn(console, "warn"); let lazyLoaderStub = jest.fn(async () => { @@ -12360,14 +12500,14 @@ describe("a router", () => { }, ]); - let e; + let err; try { await queryRoute(createRequest("/lazy")); - } catch (_e) { - e = _e; + } catch (_err) { + err = _err; } - expect(e).toMatchInlineSnapshot(`[Error: LAZY LOADER ERROR]`); + expect(err?.message).toBe("LAZY LOADER ERROR"); }); }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 1e765d242e..63f9b4abfb 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -873,7 +873,18 @@ export function createRouter(init: RouterInit): Router { } // Load lazy modules, then kick off initial data load if needed - loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest).then(() => { + loadLazyRouteModules( + lazyMatches, + state.matches, + hasErrorBoundary, + manifest + ).then(({ errors }) => { + if (errors) { + // TODO: Find a different approach? I don't this will work if we still + // need to call router loaders load since it'll override the errors. + updateState({ errors }); + } + let initialized = !state.matches.some((m) => m.route.loader) || init.hydrationData != null; @@ -1322,12 +1333,24 @@ export function createRouter(init: RouterInit): Router { // Call our action and get the result let result: DataResult; let actionMatch = getTargetMatch(matches, location); + let lazyErrors: RouteData | null = null; if (actionMatch.route.lazy) { - await loadLazyRouteModules([actionMatch], hasErrorBoundary, manifest); + let { errors } = await loadLazyRouteModules( + [actionMatch], + matches, + hasErrorBoundary, + manifest + ); + lazyErrors = errors; } - if (!actionMatch.route.action) { + if (lazyErrors) { + result = { + type: ResultType.error, + error: Object.values(lazyErrors)[0], + }; + } else if (!actionMatch.route.action) { result = { type: ResultType.error, error: getInternalRouterError(405, { @@ -1510,13 +1533,31 @@ export function createRouter(init: RouterInit): Router { ); let lazyMatches = matches.filter((m) => m.route.lazy); + let routeIdsToSkipLoading: string[] | null = null; + let lazyErrors: RouteData | null = null; if (lazyMatches.length > 0) { - await loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest); + let lazyResult = await loadLazyRouteModules( + lazyMatches, + matches, + hasErrorBoundary, + manifest + ); + + lazyErrors = lazyResult.errors; + routeIdsToSkipLoading = lazyResult.routeIdsToSkipLoading; + if (request.signal.aborted) { return { shortCircuited: true }; } } + // Avoid running loaders for lazy routes that errored + if (routeIdsToSkipLoading) { + matchesToLoad = matchesToLoad.filter( + (m) => !routeIdsToSkipLoading!.includes(m.route.id) + ); + } + let { results, loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( state.matches, @@ -1543,7 +1584,7 @@ export function createRouter(init: RouterInit): Router { } // Process and commit output from loaders - let { loaderData, errors } = processLoaderData( + let { loaderData, errors: loaderErrors } = processLoaderData( state, matches, matchesToLoad, @@ -1554,6 +1595,9 @@ export function createRouter(init: RouterInit): Router { activeDeferreds ); + let hasErrors = lazyErrors || loaderErrors; + let errors = hasErrors ? { ...lazyErrors, ...loaderErrors } : null; + // Wire up subscribers to update loaderData as promises settle activeDeferreds.forEach((deferredData, routeId) => { deferredData.subscribe((aborted) => { @@ -1670,7 +1714,17 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); if (match.route.lazy) { - await loadLazyRouteModules([match], hasErrorBoundary, manifest); + let { errors } = await loadLazyRouteModules( + [match], + state.matches, + hasErrorBoundary, + manifest + ); + + if (errors) { + setFetcherError(key, routeId, Object.values(errors)[0]); + return; + } if (!match.route.action) { let error = getInternalRouterError(405, { @@ -1793,7 +1847,16 @@ export function createRouter(init: RouterInit): Router { let lazyMatches = matches.filter((m) => m.route.lazy); if (match.route.lazy) { - await loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest); + // TODO: Add tests for matched loaders that fire after a fetcher action + let { errors } = await loadLazyRouteModules( + lazyMatches, + matches, + hasErrorBoundary, + manifest + ); + if (errors) { + throw new Error("TODO: Handle lazy errors in handleFetcherAction"); + } if (revalidationRequest.signal.aborted) { return; } @@ -1913,20 +1976,39 @@ export function createRouter(init: RouterInit): Router { ); fetchControllers.set(key, abortController); + let lazyErrorResult: ErrorResult | null = null; if (match.route.lazy) { - await loadLazyRouteModules([match], hasErrorBoundary, manifest); + let { errors } = await loadLazyRouteModules( + [match], + matches, + hasErrorBoundary, + manifest + ); + if (fetchRequest.signal.aborted) { return; } + + if (errors) { + lazyErrorResult = { + type: ResultType.error, + error: Object.values(errors)[0], + }; + } } - let result: DataResult = await callLoaderOrAction( - "loader", - fetchRequest, - match, - matches, - router.basename - ); + let result: DataResult; + if (lazyErrorResult) { + result = lazyErrorResult; + } else { + result = await callLoaderOrAction( + "loader", + fetchRequest, + match, + matches, + router.basename + ); + } // Deferred isn't supported for fetcher loads, await everything and treat it // as a normal load. resolveDeferredData will return undefined if this @@ -2634,7 +2716,18 @@ export function createStaticHandler( let lazyMatches = matches.filter((m) => m.route.lazy); if (lazyMatches.length > 0) { - await loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest); + let { errors } = await loadLazyRouteModules( + lazyMatches, + matches, + hasErrorBoundary, + manifest + ); + if (errors) { + // TODO: Confirm this is the right approach. The assumption here is that + // since this is running on the server and all route modules should be + // available, errors here are not recoverable. + throw Object.values(errors)[0]; + } if (request.signal.aborted) { let method = routeMatch != null ? "queryRoute" : "query"; throw new Error(`${method}() call aborted`); @@ -3186,12 +3279,32 @@ function shouldRevalidateLoader( */ async function loadLazyRouteModules( lazyMatches: AgnosticDataRouteMatch[], + matches: AgnosticDataRouteMatch[], hasErrorBoundary: HasErrorBoundaryFunction, manifest: RouteManifest -) { - await Promise.all( +): Promise<{ + errors: RouteData | null; + routeIdsToSkipLoading: string[] | null; +}> { + let errors: RouteData | null = null; + let routeIdsToSkipLoading: string[] | null = null; + await Promise.allSettled( lazyMatches.map(async (match) => { - let lazyRoute = await match.route.lazy!(); + let lazyRoute; + try { + lazyRoute = await match.route.lazy!(); + } catch (error) { + errors = errors || {}; + let boundaryMatch = findNearestBoundary(matches, match.route.id).route; + errors[boundaryMatch.id] = error; + + routeIdsToSkipLoading = routeIdsToSkipLoading || []; + // TODO: Also skip any routes between the lazy route and its nearest + // boundary, and ensure that this is tested + routeIdsToSkipLoading.push(match.route.id); + + return; + } // If the lazy route function has already been executed and removed from // the route object by another call while we were waiting for the promise @@ -3251,6 +3364,8 @@ async function loadLazyRouteModules( }); }) ); + + return { errors, routeIdsToSkipLoading }; } async function callLoaderOrAction( From ab8bd20cd35b82393bc467c2dbc2e1e2aa828ff7 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 17 Feb 2023 16:32:06 -0500 Subject: [PATCH 34/64] Partial Revert "WIP lazy load error handling" Only removes the code changes but kept the tests to use against the new approach This reverts commit 890cab340e47c5205cfd54739827c62346a56a2a. --- packages/react-router/lib/hooks.tsx | 5 +- packages/router/router.ts | 153 ++++------------------------ 2 files changed, 20 insertions(+), 138 deletions(-) diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index c7451a11b0..f766c26e83 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -392,10 +392,7 @@ export function useRoutes( warning( matches == null || - matches[matches.length - 1].route.element !== undefined || - // If the route is lazy and has failed to resolve, which means the lazy - // function is still present, it's possible to not have an element. - matches[matches.length - 1].route.lazy !== undefined, + matches[matches.length - 1].route.element !== undefined, `Matched leaf route at location "${location.pathname}${location.search}${location.hash}" does not have an element. ` + `This means it will render an with a null value by default resulting in an "empty" page.` ); diff --git a/packages/router/router.ts b/packages/router/router.ts index 63f9b4abfb..1e765d242e 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -873,18 +873,7 @@ export function createRouter(init: RouterInit): Router { } // Load lazy modules, then kick off initial data load if needed - loadLazyRouteModules( - lazyMatches, - state.matches, - hasErrorBoundary, - manifest - ).then(({ errors }) => { - if (errors) { - // TODO: Find a different approach? I don't this will work if we still - // need to call router loaders load since it'll override the errors. - updateState({ errors }); - } - + loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest).then(() => { let initialized = !state.matches.some((m) => m.route.loader) || init.hydrationData != null; @@ -1333,24 +1322,12 @@ export function createRouter(init: RouterInit): Router { // Call our action and get the result let result: DataResult; let actionMatch = getTargetMatch(matches, location); - let lazyErrors: RouteData | null = null; if (actionMatch.route.lazy) { - let { errors } = await loadLazyRouteModules( - [actionMatch], - matches, - hasErrorBoundary, - manifest - ); - lazyErrors = errors; + await loadLazyRouteModules([actionMatch], hasErrorBoundary, manifest); } - if (lazyErrors) { - result = { - type: ResultType.error, - error: Object.values(lazyErrors)[0], - }; - } else if (!actionMatch.route.action) { + if (!actionMatch.route.action) { result = { type: ResultType.error, error: getInternalRouterError(405, { @@ -1533,31 +1510,13 @@ export function createRouter(init: RouterInit): Router { ); let lazyMatches = matches.filter((m) => m.route.lazy); - let routeIdsToSkipLoading: string[] | null = null; - let lazyErrors: RouteData | null = null; if (lazyMatches.length > 0) { - let lazyResult = await loadLazyRouteModules( - lazyMatches, - matches, - hasErrorBoundary, - manifest - ); - - lazyErrors = lazyResult.errors; - routeIdsToSkipLoading = lazyResult.routeIdsToSkipLoading; - + await loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest); if (request.signal.aborted) { return { shortCircuited: true }; } } - // Avoid running loaders for lazy routes that errored - if (routeIdsToSkipLoading) { - matchesToLoad = matchesToLoad.filter( - (m) => !routeIdsToSkipLoading!.includes(m.route.id) - ); - } - let { results, loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( state.matches, @@ -1584,7 +1543,7 @@ export function createRouter(init: RouterInit): Router { } // Process and commit output from loaders - let { loaderData, errors: loaderErrors } = processLoaderData( + let { loaderData, errors } = processLoaderData( state, matches, matchesToLoad, @@ -1595,9 +1554,6 @@ export function createRouter(init: RouterInit): Router { activeDeferreds ); - let hasErrors = lazyErrors || loaderErrors; - let errors = hasErrors ? { ...lazyErrors, ...loaderErrors } : null; - // Wire up subscribers to update loaderData as promises settle activeDeferreds.forEach((deferredData, routeId) => { deferredData.subscribe((aborted) => { @@ -1714,17 +1670,7 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(key, abortController); if (match.route.lazy) { - let { errors } = await loadLazyRouteModules( - [match], - state.matches, - hasErrorBoundary, - manifest - ); - - if (errors) { - setFetcherError(key, routeId, Object.values(errors)[0]); - return; - } + await loadLazyRouteModules([match], hasErrorBoundary, manifest); if (!match.route.action) { let error = getInternalRouterError(405, { @@ -1847,16 +1793,7 @@ export function createRouter(init: RouterInit): Router { let lazyMatches = matches.filter((m) => m.route.lazy); if (match.route.lazy) { - // TODO: Add tests for matched loaders that fire after a fetcher action - let { errors } = await loadLazyRouteModules( - lazyMatches, - matches, - hasErrorBoundary, - manifest - ); - if (errors) { - throw new Error("TODO: Handle lazy errors in handleFetcherAction"); - } + await loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest); if (revalidationRequest.signal.aborted) { return; } @@ -1976,39 +1913,20 @@ export function createRouter(init: RouterInit): Router { ); fetchControllers.set(key, abortController); - let lazyErrorResult: ErrorResult | null = null; if (match.route.lazy) { - let { errors } = await loadLazyRouteModules( - [match], - matches, - hasErrorBoundary, - manifest - ); - + await loadLazyRouteModules([match], hasErrorBoundary, manifest); if (fetchRequest.signal.aborted) { return; } - - if (errors) { - lazyErrorResult = { - type: ResultType.error, - error: Object.values(errors)[0], - }; - } } - let result: DataResult; - if (lazyErrorResult) { - result = lazyErrorResult; - } else { - result = await callLoaderOrAction( - "loader", - fetchRequest, - match, - matches, - router.basename - ); - } + let result: DataResult = await callLoaderOrAction( + "loader", + fetchRequest, + match, + matches, + router.basename + ); // Deferred isn't supported for fetcher loads, await everything and treat it // as a normal load. resolveDeferredData will return undefined if this @@ -2716,18 +2634,7 @@ export function createStaticHandler( let lazyMatches = matches.filter((m) => m.route.lazy); if (lazyMatches.length > 0) { - let { errors } = await loadLazyRouteModules( - lazyMatches, - matches, - hasErrorBoundary, - manifest - ); - if (errors) { - // TODO: Confirm this is the right approach. The assumption here is that - // since this is running on the server and all route modules should be - // available, errors here are not recoverable. - throw Object.values(errors)[0]; - } + await loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest); if (request.signal.aborted) { let method = routeMatch != null ? "queryRoute" : "query"; throw new Error(`${method}() call aborted`); @@ -3279,32 +3186,12 @@ function shouldRevalidateLoader( */ async function loadLazyRouteModules( lazyMatches: AgnosticDataRouteMatch[], - matches: AgnosticDataRouteMatch[], hasErrorBoundary: HasErrorBoundaryFunction, manifest: RouteManifest -): Promise<{ - errors: RouteData | null; - routeIdsToSkipLoading: string[] | null; -}> { - let errors: RouteData | null = null; - let routeIdsToSkipLoading: string[] | null = null; - await Promise.allSettled( +) { + await Promise.all( lazyMatches.map(async (match) => { - let lazyRoute; - try { - lazyRoute = await match.route.lazy!(); - } catch (error) { - errors = errors || {}; - let boundaryMatch = findNearestBoundary(matches, match.route.id).route; - errors[boundaryMatch.id] = error; - - routeIdsToSkipLoading = routeIdsToSkipLoading || []; - // TODO: Also skip any routes between the lazy route and its nearest - // boundary, and ensure that this is tested - routeIdsToSkipLoading.push(match.route.id); - - return; - } + let lazyRoute = await match.route.lazy!(); // If the lazy route function has already been executed and removed from // the route object by another call while we were waiting for the promise @@ -3364,8 +3251,6 @@ async function loadLazyRouteModules( }); }) ); - - return { errors, routeIdsToSkipLoading }; } async function callLoaderOrAction( From 88431bcfa834d1cd59ed2290a3ee7af92b495cb3 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 17 Feb 2023 17:46:41 -0500 Subject: [PATCH 35/64] Move route.lazy() execution into callLoaderOrAction --- packages/router/__tests__/router-test.ts | 16 +- packages/router/router.ts | 280 ++++++++++++----------- 2 files changed, 156 insertions(+), 140 deletions(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 00b2a40d5a..745ee504c8 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -12282,14 +12282,14 @@ describe("a router", () => { }, ]); - let err; - try { - await query(createRequest("/lazy")); - } catch (_err) { - err = _err; - } - - expect(err?.message).toBe("LAZY FUNCTION ERROR"); + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); }); it("keeps existing loader when using staticHandler.query() if static loader is already defined", async () => { diff --git a/packages/router/router.ts b/packages/router/router.ts index 1e765d242e..fb49f9fb7c 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -873,7 +873,10 @@ export function createRouter(init: RouterInit): Router { } // Load lazy modules, then kick off initial data load if needed - loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest).then(() => { + let lazyPromises = lazyMatches.map((m) => + loadLazyRouteModule(m.route, hasErrorBoundary, manifest) + ); + Promise.all(lazyPromises).then(() => { let initialized = !state.matches.some((m) => m.route.loader) || init.hydrationData != null; @@ -1323,11 +1326,7 @@ export function createRouter(init: RouterInit): Router { let result: DataResult; let actionMatch = getTargetMatch(matches, location); - if (actionMatch.route.lazy) { - await loadLazyRouteModules([actionMatch], hasErrorBoundary, manifest); - } - - if (!actionMatch.route.action) { + if (!actionMatch.route.action && !actionMatch.route.lazy) { result = { type: ResultType.error, error: getInternalRouterError(405, { @@ -1342,6 +1341,8 @@ export function createRouter(init: RouterInit): Router { request, actionMatch, matches, + manifest, + hasErrorBoundary, router.basename ); @@ -1509,14 +1510,6 @@ export function createRouter(init: RouterInit): Router { fetchControllers.set(rf.key, pendingNavigationController!) ); - let lazyMatches = matches.filter((m) => m.route.lazy); - if (lazyMatches.length > 0) { - await loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest); - if (request.signal.aborted) { - return { shortCircuited: true }; - } - } - let { results, loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( state.matches, @@ -1638,7 +1631,7 @@ export function createRouter(init: RouterInit): Router { interruptActiveLoads(); fetchLoadMatches.delete(key); - if (!match.route.lazy && !match.route.action) { + if (!match.route.action && !match.route.lazy) { let error = getInternalRouterError(405, { method: submission.formMethod, pathname: path, @@ -1669,25 +1662,13 @@ export function createRouter(init: RouterInit): Router { ); fetchControllers.set(key, abortController); - if (match.route.lazy) { - await loadLazyRouteModules([match], hasErrorBoundary, manifest); - - if (!match.route.action) { - let error = getInternalRouterError(405, { - method: submission.formMethod, - pathname: path, - routeId: routeId, - }); - setFetcherError(key, routeId, error); - return; - } - } - let actionResult = await callLoaderOrAction( "action", fetchRequest, match, requestMatches, + manifest, + hasErrorBoundary, router.basename ); @@ -1791,14 +1772,6 @@ export function createRouter(init: RouterInit): Router { updateState({ fetchers: new Map(state.fetchers) }); - let lazyMatches = matches.filter((m) => m.route.lazy); - if (match.route.lazy) { - await loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest); - if (revalidationRequest.signal.aborted) { - return; - } - } - let { results, loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( state.matches, @@ -1913,18 +1886,13 @@ export function createRouter(init: RouterInit): Router { ); fetchControllers.set(key, abortController); - if (match.route.lazy) { - await loadLazyRouteModules([match], hasErrorBoundary, manifest); - if (fetchRequest.signal.aborted) { - return; - } - } - let result: DataResult = await callLoaderOrAction( "loader", fetchRequest, match, matches, + manifest, + hasErrorBoundary, router.basename ); @@ -2118,7 +2086,15 @@ export function createRouter(init: RouterInit): Router { // accordingly let results = await Promise.all([ ...matchesToLoad.map((match) => - callLoaderOrAction("loader", request, match, matches, router.basename) + callLoaderOrAction( + "loader", + request, + match, + matches, + manifest, + hasErrorBoundary, + router.basename + ) ), ...fetchersToLoad.map((f) => callLoaderOrAction( @@ -2126,6 +2102,8 @@ export function createRouter(init: RouterInit): Router { createClientSideRequest(init.history, f.path, request.signal), f.match, f.matches, + manifest, + hasErrorBoundary, router.basename ) ), @@ -2632,15 +2610,6 @@ export function createStaticHandler( "query()/queryRoute() requests must contain an AbortController signal" ); - let lazyMatches = matches.filter((m) => m.route.lazy); - if (lazyMatches.length > 0) { - await loadLazyRouteModules(lazyMatches, hasErrorBoundary, manifest); - if (request.signal.aborted) { - let method = routeMatch != null ? "queryRoute" : "query"; - throw new Error(`${method}() call aborted`); - } - } - try { if (isMutationMethod(request.method.toLowerCase())) { let result = await submit( @@ -2694,7 +2663,7 @@ export function createStaticHandler( ): Promise | Response> { let result: DataResult; - if (!actionMatch.route.action) { + if (!actionMatch.route.action && !actionMatch.route.lazy) { let error = getInternalRouterError(405, { method: request.method, pathname: new URL(request.url).pathname, @@ -2713,6 +2682,8 @@ export function createStaticHandler( request, actionMatch, matches, + manifest, + hasErrorBoundary, basename, true, isRouteRequest, @@ -2834,7 +2805,11 @@ export function createStaticHandler( let isRouteRequest = routeMatch != null; // Short circuit if we have no loaders to run (queryRoute()) - if (isRouteRequest && !routeMatch?.route.loader) { + if ( + isRouteRequest && + !routeMatch?.route.loader && + !routeMatch?.route.lazy + ) { throw getInternalRouterError(400, { method: request.method, pathname: new URL(request.url).pathname, @@ -2848,7 +2823,9 @@ export function createStaticHandler( matches, Object.keys(pendingActionError || {})[0] ); - let matchesToLoad = requestMatches.filter((m) => m.route.loader); + let matchesToLoad = requestMatches.filter( + (m) => m.route.loader || m.route.lazy + ); // Short circuit if we have no loaders to run (query()) if (matchesToLoad.length === 0) { @@ -2873,6 +2850,8 @@ export function createStaticHandler( request, match, matches, + manifest, + hasErrorBoundary, basename, true, isRouteRequest, @@ -3184,73 +3163,77 @@ function shouldRevalidateLoader( * shouldRevalidate) and update the routeManifest in place which shares objects * with dataRoutes so those get updated as well. */ -async function loadLazyRouteModules( - lazyMatches: AgnosticDataRouteMatch[], +async function loadLazyRouteModule( + route: AgnosticDataRouteObject, hasErrorBoundary: HasErrorBoundaryFunction, manifest: RouteManifest ) { - await Promise.all( - lazyMatches.map(async (match) => { - let lazyRoute = await match.route.lazy!(); - - // If the lazy route function has already been executed and removed from - // the route object by another call while we were waiting for the promise - // to resolve then we don't want to resolve the same route again. - if (!match.route.lazy) { - return; - } + if (!route.lazy) { + return; + } - let routeToUpdate = manifest[match.route.id]; - invariant(routeToUpdate, "No route found in manifest"); - - // For now, we update in place. We think this is ok since there's no way - // we could yet be sitting on this route since we can't get there without - // resolving through here first. This is different than the HMR "update" - // use-case where we may actively be on the route being updated. The main - // concern boils down to "does this mutation affect any ongoing navigations - // or any current state.matches values?". If not, I think it's safe to - // mutate in place. It's also worth noting that this is a more targeted - // update that cannot touch things like path/index/children so it cannot - // affect the routes we've already matched. - let routeUpdates: Record = {}; - for (let lazyRouteProperty in lazyRoute) { - let staticRouteValue = - routeToUpdate[lazyRouteProperty as keyof typeof routeToUpdate]; - - let isPropertyStaticallyDefined = - staticRouteValue !== undefined && - lazyRouteProperty !== "hasErrorBoundary"; // This property isn't static since it should always be updated based on the route updates + let lazyRoute = await route.lazy(); - warning( - !isPropertyStaticallyDefined, - `Route "${routeToUpdate.id}" has a static property "${lazyRouteProperty}" defined but its lazy function is also returning a value for this property. The lazy route property "${lazyRouteProperty}" will be ignored.` - ); + // If the lazy route function has already been executed and removed from + // the route object by another call while we were waiting for the promise + // to resolve then we don't want to resolve the same route again. + if (!route.lazy) { + return; + } - if ( - !isPropertyStaticallyDefined && - !immutableRouteKeys.has(lazyRouteProperty as ImmutableRouteKey) - ) { - routeUpdates[lazyRouteProperty] = - lazyRoute[lazyRouteProperty as keyof typeof lazyRoute]; - } - } + let routeToUpdate = manifest[route.id]; + invariant(routeToUpdate, "No route found in manifest"); + + // For now, we update in place. We think this is ok since there's no way + // we could yet be sitting on this route since we can't get there without + // resolving through here first. This is different than the HMR "update" + // use-case where we may actively be on the route being updated. The main + // concern boils down to "does this mutation affect any ongoing navigations + // or any current state.matches values?". If not, I think it's safe to + // mutate in place. It's also worth noting that this is a more targeted + // update that cannot touch things like path/index/children so it cannot + // affect the routes we've already matched. + let routeUpdates: Record = {}; + for (let lazyRouteProperty in lazyRoute) { + let staticRouteValue = + routeToUpdate[lazyRouteProperty as keyof typeof routeToUpdate]; + + let isPropertyStaticallyDefined = + staticRouteValue !== undefined && + // This property isn't static since it should always be updated based + // on the route updates + lazyRouteProperty !== "hasErrorBoundary"; + + warning( + !isPropertyStaticallyDefined, + `Route "${routeToUpdate.id}" has a static property "${lazyRouteProperty}" ` + + `defined but its lazy function is also returning a value for this property. ` + + `The lazy route property "${lazyRouteProperty}" will be ignored.` + ); - // Mutate the route with the provided updates. Do this first so we pass - // the updated version to hasErrorBoundary - Object.assign(routeToUpdate, routeUpdates); - - // Mutate the `hasErrorBoundary` property on the route based on the route - // updates and remove the `lazy` function so we don't resolve the lazy - // route again. - Object.assign(routeToUpdate, { - // To keep things framework agnostic, we use the provided - // `hasErrorBoundary` function to set the `hasErrorBoundary` route - // property since the logic will differ between frameworks. - hasErrorBoundary: hasErrorBoundary({ ...routeToUpdate }), - lazy: undefined, - }); - }) - ); + if ( + !isPropertyStaticallyDefined && + !immutableRouteKeys.has(lazyRouteProperty as ImmutableRouteKey) + ) { + routeUpdates[lazyRouteProperty] = + lazyRoute[lazyRouteProperty as keyof typeof lazyRoute]; + } + } + + // Mutate the route with the provided updates. Do this first so we pass + // the updated version to hasErrorBoundary + Object.assign(routeToUpdate, routeUpdates); + + // Mutate the `hasErrorBoundary` property on the route based on the route + // updates and remove the `lazy` function so we don't resolve the lazy + // route again. + Object.assign(routeToUpdate, { + // To keep things framework agnostic, we use the provided + // `hasErrorBoundary` function to set the `hasErrorBoundary` route + // property since the logic will differ between frameworks. + hasErrorBoundary: hasErrorBoundary({ ...routeToUpdate }), + lazy: undefined, + }); } async function callLoaderOrAction( @@ -3258,6 +3241,8 @@ async function callLoaderOrAction( request: Request, match: AgnosticDataRouteMatch, matches: AgnosticDataRouteMatch[], + manifest: RouteManifest, + hasErrorBoundary: HasErrorBoundaryFunction, basename = "/", isStaticRequest: boolean = false, isRouteRequest: boolean = false, @@ -3269,27 +3254,58 @@ async function callLoaderOrAction( // Setup a promise we can race against so that abort signals short circuit let reject: () => void; let abortPromise = new Promise((_, r) => (reject = r)); + // Ensure we don't have any unhandled exceptions on rejection + abortPromise.catch(() => {}); let onReject = () => reject(); request.signal.addEventListener("abort", onReject); try { - let handler = match.route[type]; - invariant( - handler, - `Could not find the ${type} to run on the "${match.route.id}" route` - ); + // Load any lazy route modules as part of the loader/action phase + if (match.route.lazy) { + await loadLazyRouteModule(match.route, hasErrorBoundary, manifest); + } else { + invariant( + match.route[type], + `Could not find the ${type} to run on the "${match.route.id}" route` + ); + } - result = await Promise.race([ - handler({ request, params: match.params, context: requestContext }), - abortPromise, - ]); + let handler = match.route[type]; + if (!handler) { + if (type === "action") { + throw getInternalRouterError(405, { + method: request.method, + pathname: new URL(request.url).pathname, + routeId: match.route.id, + }); + } else { + // lazy() route has no loader to run + result = undefined; + } + } else { + if (!request.signal.aborted || type === "action") { + // Still kick off actions if we got interrupted to maintain consistency + // with un-abortable behavior of action execution on non-lazy routes + result = await Promise.race([ + handler({ + request, + params: match.params, + context: requestContext, + }), + abortPromise, + ]); + } else { + // No need to run loaders if we got aborted during lazy() + result = await abortPromise; + } - invariant( - result !== undefined, - `You defined ${type === "action" ? "an action" : "a loader"} for route ` + - `"${match.route.id}" but didn't return anything from your \`${type}\` ` + - `function. Please return a value or \`null\`.` - ); + invariant( + result !== undefined, + `You defined ${type === "action" ? "an action" : "a loader"} ` + + `for route "${match.route.id}" but didn't return anything from your ` + + `\`${type}\` function. Please return a value or \`null\`.` + ); + } } catch (e) { resultType = ResultType.error; result = e; From 5656963687c742a615f32c7808e3524dc820dceb Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 21 Feb 2023 17:21:55 -0500 Subject: [PATCH 36/64] Rename/update changeset --- .../{many-frogs-accept.md => lazy-route-modules.md} | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) rename .changeset/{many-frogs-accept.md => lazy-route-modules.md} (87%) diff --git a/.changeset/many-frogs-accept.md b/.changeset/lazy-route-modules.md similarity index 87% rename from .changeset/many-frogs-accept.md rename to .changeset/lazy-route-modules.md index 09439199a3..78c019ec49 100644 --- a/.changeset/many-frogs-accept.md +++ b/.changeset/lazy-route-modules.md @@ -28,8 +28,8 @@ let routes = createRoutesFromElements( Then in your lazy route modules, export the properties you want defined for the route: ```jsx -export function loader({ request }) { - let data = fetchData(request); +export async function loader({ request }) { + let data = await fetchData(request); return json(data); } @@ -47,7 +47,14 @@ function Component() { export const element = ; function ErrorBoundary() { - return

    Something went wrong

    ; + let error = useRouteError(); + return isRouteErrorResponse(error) ? ( +

    + {error.status} {error.statusText} +

    + ) : ( +

    {error.message || error}

    + ); } export const errorElement = ; From 0826818626c64f86052c9370aad378be9a30a706 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 21 Feb 2023 17:22:17 -0500 Subject: [PATCH 37/64] Remove uneeded empty abortPromise.catch --- packages/router/router.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index a43bedff0d..a18ec5c094 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3251,13 +3251,7 @@ async function callLoaderOrAction( let resultType; let result; - // Setup a promise we can race against so that abort signals short circuit - let reject: () => void; - let abortPromise = new Promise((_, r) => (reject = r)); - // Ensure we don't have any unhandled exceptions on rejection - abortPromise.catch(() => {}); - let onReject = () => reject(); - request.signal.addEventListener("abort", onReject); + let onReject: (() => void) | undefined; try { // Load any lazy route modules as part of the loader/action phase @@ -3283,6 +3277,12 @@ async function callLoaderOrAction( result = undefined; } } else { + // Setup a promise we can race against so that abort signals short circuit + let reject: () => void; + let abortPromise = new Promise((_, r) => (reject = r)); + onReject = () => reject(); + request.signal.addEventListener("abort", onReject); + if (!request.signal.aborted || type === "action") { // Still kick off actions if we got interrupted to maintain consistency // with un-abortable behavior of action execution on non-lazy routes @@ -3310,7 +3310,9 @@ async function callLoaderOrAction( resultType = ResultType.error; result = e; } finally { - request.signal.removeEventListener("abort", onReject); + if (onReject) { + request.signal.removeEventListener("abort", onReject); + } } if (isResponse(result)) { From e673bb0d1bcf492fcd9f7d95b38467a2746438d7 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 21 Feb 2023 17:24:18 -0500 Subject: [PATCH 38/64] Extract to standalone lazy example --- .changeset/lazy-route-modules.md | 2 + examples/data-router/src/app.tsx | 35 +- examples/data-router/src/lazy.tsx | 70 -- .../lazy-loading-router-provider/.gitignore | 5 + .../.stackblitzrc | 4 + .../lazy-loading-router-provider/README.md | 14 + .../lazy-loading-router-provider/index.html | 12 + .../package-lock.json | 1095 +++++++++++++++++ .../lazy-loading-router-provider/package.json | 23 + .../lazy-loading-router-provider/src/App.tsx | 136 ++ .../src/index.css | 12 + .../lazy-loading-router-provider/src/main.tsx | 11 + .../src/pages/About.tsx | 20 + .../src/pages/Dashboard.tsx | 61 + .../src/vite-env.d.ts | 1 + .../tsconfig.json | 21 + .../vite.config.ts | 36 + examples/ssr-data-router/src/lazy.tsx | 48 +- 18 files changed, 1456 insertions(+), 150 deletions(-) delete mode 100644 examples/data-router/src/lazy.tsx create mode 100644 examples/lazy-loading-router-provider/.gitignore create mode 100644 examples/lazy-loading-router-provider/.stackblitzrc create mode 100644 examples/lazy-loading-router-provider/README.md create mode 100644 examples/lazy-loading-router-provider/index.html create mode 100644 examples/lazy-loading-router-provider/package-lock.json create mode 100644 examples/lazy-loading-router-provider/package.json create mode 100644 examples/lazy-loading-router-provider/src/App.tsx create mode 100644 examples/lazy-loading-router-provider/src/index.css create mode 100644 examples/lazy-loading-router-provider/src/main.tsx create mode 100644 examples/lazy-loading-router-provider/src/pages/About.tsx create mode 100644 examples/lazy-loading-router-provider/src/pages/Dashboard.tsx create mode 100644 examples/lazy-loading-router-provider/src/vite-env.d.ts create mode 100644 examples/lazy-loading-router-provider/tsconfig.json create mode 100644 examples/lazy-loading-router-provider/vite.config.ts diff --git a/.changeset/lazy-route-modules.md b/.changeset/lazy-route-modules.md index 78c019ec49..ba1240f308 100644 --- a/.changeset/lazy-route-modules.md +++ b/.changeset/lazy-route-modules.md @@ -60,4 +60,6 @@ function ErrorBoundary() { export const errorElement = ; ``` +An example of this in action can be found in the [`examples/lazy-loading-router-provider`](https://github.com/remix-run/react-router/tree/main/examples/lazy-loading-router-provider) directory of the repository. + 🙌 Huge thanks to @rossipedia for the [Initial Proposal](https://github.com/remix-run/react-router/discussions/9826) and [POC Implementation](https://github.com/remix-run/react-router/pull/9830). diff --git a/examples/data-router/src/app.tsx b/examples/data-router/src/app.tsx index 23c0d8574b..cf871c76a3 100644 --- a/examples/data-router/src/app.tsx +++ b/examples/data-router/src/app.tsx @@ -44,7 +44,6 @@ let router = createBrowserRouter( loader={deferredLoader} element={} /> - import("./lazy")} /> ) ); @@ -69,11 +68,11 @@ export function Fallback() { export function Layout() { let navigation = useNavigation(); let revalidator = useRevalidator(); - let lazyFetcher = useFetcher(); let fetchers = useFetchers(); let fetcherInProgress = fetchers.some((f) => ["loading", "submitting"].includes(f.state) ); + return ( <>

    Data Router Example

    @@ -96,46 +95,14 @@ export function Layout() {
  • Deferred
  • -
  • - Lazy -
  • 404 Link
  • -
  • -
    - -
    -
  • -
  • - -    - - fetcher state/data: {lazyFetcher.state}/ - {JSON.stringify(lazyFetcher.data)} - -
  • -
  • - -    - - fetcher state/data: {lazyFetcher.state}/ - {JSON.stringify(lazyFetcher.data)} - -
  • diff --git a/examples/data-router/src/lazy.tsx b/examples/data-router/src/lazy.tsx deleted file mode 100644 index 964a7c0675..0000000000 --- a/examples/data-router/src/lazy.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; -import type { - ActionFunction, - ShouldRevalidateFunction, -} from "react-router-dom"; -import { Form, useLoaderData } from "react-router-dom"; - -interface LazyLoaderData { - date: string; - submissionCount: number; -} - -let submissionCount = 0; - -export const loader = async (): Promise => { - await new Promise((r) => setTimeout(r, 500)); - return { - date: new Date().toISOString(), - submissionCount, - }; -}; - -export const action: ActionFunction = async ({ request }) => { - await new Promise((r) => setTimeout(r, 500)); - - let body = await request.formData(); - if (body.get("error")) { - throw new Error("Form action error"); - } - - submissionCount++; - return submissionCount; -}; - -function ErrorBoundary() { - return ( - <> -

    Lazy error boundary

    -
    Something went wrong
    - - ); -} - -export const errorElement = ; - -export const shouldRevalidate: ShouldRevalidateFunction = (args) => { - return Boolean(args.formAction); -}; - -function LazyPage() { - let data = useLoaderData() as LazyLoaderData; - - return ( - <> -

    Lazy

    -

    Date from loader: {data.date}

    -

    Form submission count: {data.submissionCount}

    -
    -
    - - -
    -
    - - ); -} - -export const element = ; diff --git a/examples/lazy-loading-router-provider/.gitignore b/examples/lazy-loading-router-provider/.gitignore new file mode 100644 index 0000000000..d451ff16c1 --- /dev/null +++ b/examples/lazy-loading-router-provider/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local diff --git a/examples/lazy-loading-router-provider/.stackblitzrc b/examples/lazy-loading-router-provider/.stackblitzrc new file mode 100644 index 0000000000..d98146f4d0 --- /dev/null +++ b/examples/lazy-loading-router-provider/.stackblitzrc @@ -0,0 +1,4 @@ +{ + "installDependencies": true, + "startCommand": "npm run dev" +} diff --git a/examples/lazy-loading-router-provider/README.md b/examples/lazy-loading-router-provider/README.md new file mode 100644 index 0000000000..795885fe38 --- /dev/null +++ b/examples/lazy-loading-router-provider/README.md @@ -0,0 +1,14 @@ +--- +title: Lazy Loading with RouterProvider +toc: false +--- + +# Lazy Loading Example using `RouterProvider` + +This example demonstrates how to lazily load individual route elements on demand `route.lazy()` and dynamic `import()`. Using this technique, pages that are not required on the home page can be split out into separate bundles, thereby decreasing load time on the initial page and improving performance. + +## Preview + +Open this example on [StackBlitz](https://stackblitz.com): + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router/tree/main/examples/lazy-loading-router-provider?file=src/App.tsx) diff --git a/examples/lazy-loading-router-provider/index.html b/examples/lazy-loading-router-provider/index.html new file mode 100644 index 0000000000..3b9fb14a37 --- /dev/null +++ b/examples/lazy-loading-router-provider/index.html @@ -0,0 +1,12 @@ + + + + + + React Router - Lazy Loading Example using RouterProvider + + +
    + + + diff --git a/examples/lazy-loading-router-provider/package-lock.json b/examples/lazy-loading-router-provider/package-lock.json new file mode 100644 index 0000000000..627f7ad50d --- /dev/null +++ b/examples/lazy-loading-router-provider/package-lock.json @@ -0,0 +1,1095 @@ +{ + "name": "lazy-loading", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lazy-loading", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.8.0" + }, + "devDependencies": { + "@rollup/plugin-replace": "^5.0.2", + "@types/node": "18.x", + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", + "@vitejs/plugin-react": "^3.0.1", + "typescript": "^4.9.5", + "vite": "^4.0.4" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.20.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.20.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.20.7", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helpers": "^7.20.7", + "@babel/parser": "^7.20.7", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.20.12", + "@babel/types": "^7.20.7", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.2", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.20.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7", + "@jridgewell/gen-mapping": "^0.3.2", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.20.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-validator-option": "^7.18.6", + "browserslist": "^4.21.3", + "lru-cache": "^5.1.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.18.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.19.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.18.10", + "@babel/types": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.20.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-simple-access": "^7.20.2", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/helper-validator-identifier": "^7.19.1", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.20.10", + "@babel/types": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.20.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.20.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.19.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.19.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.20.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.20.13", + "@babel/types": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.18.6", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.20.13", + "dev": true, + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.19.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.20.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.18.6", + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.20.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.20.7", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/parser": "^7.20.13", + "@babel/types": "^7.20.7", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.20.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.19.4", + "@babel/helper-validator-identifier": "^7.19.1", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.16.17", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.17", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.3.1", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.27.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@types/estree": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.11.18", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.0.27", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.20.7", + "@babel/plugin-transform-react-jsx-self": "^7.18.6", + "@babel/plugin-transform-react-jsx-source": "^7.19.6", + "magic-string": "^0.27.0", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/browserslist": { + "version": "4.21.5", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001449", + "electron-to-chromium": "^1.4.284", + "node-releases": "^2.0.8", + "update-browserslist-db": "^1.0.10" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001450", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "2.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.284", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.16.17", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.16.17", + "@esbuild/android-arm64": "0.16.17", + "@esbuild/android-x64": "0.16.17", + "@esbuild/darwin-arm64": "0.16.17", + "@esbuild/darwin-x64": "0.16.17", + "@esbuild/freebsd-arm64": "0.16.17", + "@esbuild/freebsd-x64": "0.16.17", + "@esbuild/linux-arm": "0.16.17", + "@esbuild/linux-arm64": "0.16.17", + "@esbuild/linux-ia32": "0.16.17", + "@esbuild/linux-loong64": "0.16.17", + "@esbuild/linux-mips64el": "0.16.17", + "@esbuild/linux-ppc64": "0.16.17", + "@esbuild/linux-riscv64": "0.16.17", + "@esbuild/linux-s390x": "0.16.17", + "@esbuild/linux-x64": "0.16.17", + "@esbuild/netbsd-x64": "0.16.17", + "@esbuild/openbsd-x64": "0.16.17", + "@esbuild/sunos-x64": "0.16.17", + "@esbuild/win32-arm64": "0.16.17", + "@esbuild/win32-ia32": "0.16.17", + "@esbuild/win32-x64": "0.16.17" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-core-module": { + "version": "2.11.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "2.5.2", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.4", + "dev": true, + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.21", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.2.0", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-refresh": { + "version": "0.14.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.8.0", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.3.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.8.0", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.3.1", + "react-router": "6.8.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resolve": { + "version": "1.22.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "3.12.1", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.0", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.10", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "browserslist-lint": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.16.3", + "postcss": "^8.4.20", + "resolve": "^1.22.1", + "rollup": "^3.7.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + } + } +} diff --git a/examples/lazy-loading-router-provider/package.json b/examples/lazy-loading-router-provider/package.json new file mode 100644 index 0000000000..91a45834f3 --- /dev/null +++ b/examples/lazy-loading-router-provider/package.json @@ -0,0 +1,23 @@ +{ + "name": "lazy-loading-router-provider", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.8.0" + }, + "devDependencies": { + "@rollup/plugin-replace": "^5.0.2", + "@types/node": "18.x", + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", + "@vitejs/plugin-react": "^3.0.1", + "typescript": "^4.9.5", + "vite": "^4.0.4" + } +} diff --git a/examples/lazy-loading-router-provider/src/App.tsx b/examples/lazy-loading-router-provider/src/App.tsx new file mode 100644 index 0000000000..ee7cac3b36 --- /dev/null +++ b/examples/lazy-loading-router-provider/src/App.tsx @@ -0,0 +1,136 @@ +import * as React from "react"; +import { + Outlet, + Link, + createBrowserRouter, + RouterProvider, + useNavigation, +} from "react-router-dom"; + +const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { + index: true, + element: , + }, + { + path: "about", + lazy: () => import("./pages/About"), + }, + { + path: "dashboard", + async lazy() { + let { DashboardLayout } = await import("./pages/Dashboard"); + return { element: }; + }, + children: [ + { + index: true, + async lazy() { + let { DashboardIndex } = await import("./pages/Dashboard"); + return { element: }; + }, + }, + { + path: "messages", + async lazy() { + let { dashboardMessagesLoader, DashboardMessages } = await import( + "./pages/Dashboard" + ); + return { + loader: dashboardMessagesLoader, + element: , + }; + }, + }, + ], + }, + { + path: "*", + element: , + }, + ], + }, +]); + +export default function App() { + return Loading...

    } />; +} + +function Layout() { + let navigation = useNavigation(); + + return ( +
    +

    Lazy Loading Example using RouterProvider

    + +

    + This example demonstrates how to lazily load route definitions using{" "} + route.lazy(). To get the full effect of this demo, be sure + to open your Network tab and watch the new bundles load dynamically as + you navigate around. +

    + +

    + The "About" and "Dashboard" pages are not loaded until you click on the + link. When you do, the code is loaded via a dynamic{" "} + import() statement during the loading phase of + the navigation. Once the code loads, the route loader executes, and then + the element renders with the loader-provided data. +

    + +

    + This works for all data-loading/rendering related properties of a route, + including action, loader, element + , errorElement, and shouldRevalidate. You + cannot return path-matching properties from lazy() such as{" "} + path, index, children, and{" "} + caseSensitive. +

    + +
    + {navigation.state !== "idle" &&

    Navigation in progress...

    } +
    + + + +
    + + +
    + ); +} + +function Home() { + return ( +
    +

    Home

    +
    + ); +} + +function NoMatch() { + return ( +
    +

    Nothing to see here!

    +

    + Go to the home page +

    +
    + ); +} diff --git a/examples/lazy-loading-router-provider/src/index.css b/examples/lazy-loading-router-provider/src/index.css new file mode 100644 index 0000000000..3e1f253f03 --- /dev/null +++ b/examples/lazy-loading-router-provider/src/index.css @@ -0,0 +1,12 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", + monospace; +} diff --git a/examples/lazy-loading-router-provider/src/main.tsx b/examples/lazy-loading-router-provider/src/main.tsx new file mode 100644 index 0000000000..c37d979194 --- /dev/null +++ b/examples/lazy-loading-router-provider/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import "./index.css"; +import App from "./App"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + +); diff --git a/examples/lazy-loading-router-provider/src/pages/About.tsx b/examples/lazy-loading-router-provider/src/pages/About.tsx new file mode 100644 index 0000000000..44aa231613 --- /dev/null +++ b/examples/lazy-loading-router-provider/src/pages/About.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { useLoaderData } from "react-router-dom"; + +export async function loader() { + await new Promise((r) => setTimeout(r, 500)); + return "I came from the About.tsx loader function!"; +} + +function AboutPage() { + let data = useLoaderData() as string; + + return ( +
    +

    About

    +

    {data}

    +
    + ); +} + +export const element = ; diff --git a/examples/lazy-loading-router-provider/src/pages/Dashboard.tsx b/examples/lazy-loading-router-provider/src/pages/Dashboard.tsx new file mode 100644 index 0000000000..6447a79abf --- /dev/null +++ b/examples/lazy-loading-router-provider/src/pages/Dashboard.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { Outlet, Link, useLoaderData } from "react-router-dom"; + +export function DashboardLayout() { + return ( +
    + + +
    + + +
    + ); +} + +export function DashboardIndex() { + return ( +
    +

    Dashboard Index

    +
    + ); +} + +interface MessagesData { + messages: string[]; +} + +export async function dashboardMessagesLoader() { + await new Promise((r) => setTimeout(r, 500)); + return { + messages: [ + "Message 1 from Dashboard.tsx loader", + "Message 2 from Dashboard.tsx loader", + "Message 3 from Dashboard.tsx loader", + ], + } as MessagesData; +} + +export function DashboardMessages() { + let { messages } = useLoaderData() as MessagesData; + + return ( +
    +

    Messages

    +
      + {messages.map((m) => ( +
    • {m}
    • + ))} +
    +
    + ); +} diff --git a/examples/lazy-loading-router-provider/src/vite-env.d.ts b/examples/lazy-loading-router-provider/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/lazy-loading-router-provider/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/lazy-loading-router-provider/tsconfig.json b/examples/lazy-loading-router-provider/tsconfig.json new file mode 100644 index 0000000000..8bdaabfe5d --- /dev/null +++ b/examples/lazy-loading-router-provider/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "importsNotUsedAsValues": "error" + }, + "include": ["./src"] +} diff --git a/examples/lazy-loading-router-provider/vite.config.ts b/examples/lazy-loading-router-provider/vite.config.ts new file mode 100644 index 0000000000..b77eb48a30 --- /dev/null +++ b/examples/lazy-loading-router-provider/vite.config.ts @@ -0,0 +1,36 @@ +import * as path from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import rollupReplace from "@rollup/plugin-replace"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + rollupReplace({ + preventAssignment: true, + values: { + __DEV__: JSON.stringify(true), + "process.env.NODE_ENV": JSON.stringify("development"), + }, + }), + react(), + ], + resolve: process.env.USE_SOURCE + ? { + alias: { + "@remix-run/router": path.resolve( + __dirname, + "../../packages/router/index.ts" + ), + "react-router": path.resolve( + __dirname, + "../../packages/react-router/index.ts" + ), + "react-router-dom": path.resolve( + __dirname, + "../../packages/react-router-dom/index.tsx" + ), + }, + } + : {}, +}); diff --git a/examples/ssr-data-router/src/lazy.tsx b/examples/ssr-data-router/src/lazy.tsx index 964a7c0675..9e9a6b609c 100644 --- a/examples/ssr-data-router/src/lazy.tsx +++ b/examples/ssr-data-router/src/lazy.tsx @@ -1,68 +1,24 @@ import React from "react"; -import type { - ActionFunction, - ShouldRevalidateFunction, -} from "react-router-dom"; -import { Form, useLoaderData } from "react-router-dom"; +import { useLoaderData } from "react-router-dom"; interface LazyLoaderData { date: string; - submissionCount: number; } -let submissionCount = 0; - export const loader = async (): Promise => { await new Promise((r) => setTimeout(r, 500)); return { date: new Date().toISOString(), - submissionCount, }; }; -export const action: ActionFunction = async ({ request }) => { - await new Promise((r) => setTimeout(r, 500)); - - let body = await request.formData(); - if (body.get("error")) { - throw new Error("Form action error"); - } - - submissionCount++; - return submissionCount; -}; - -function ErrorBoundary() { - return ( - <> -

    Lazy error boundary

    -
    Something went wrong
    - - ); -} - -export const errorElement = ; - -export const shouldRevalidate: ShouldRevalidateFunction = (args) => { - return Boolean(args.formAction); -}; - function LazyPage() { let data = useLoaderData() as LazyLoaderData; return ( <> -

    Lazy

    +

    Lazy Route

    Date from loader: {data.date}

    -

    Form submission count: {data.submissionCount}

    -
    -
    - - -
    -
    ); } From 7f9b7b5d2d4668c56d11e275175a2b94ad2e670c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 21 Feb 2023 17:32:02 -0500 Subject: [PATCH 39/64] Fix tests --- packages/router/__tests__/router-test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 06e112a6c5..88107477d7 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -14628,12 +14628,14 @@ describe("a router", () => { id: "index", loader: true, action: true, + hasErrorBoundary: false, }, { path: "/foo", id: "foo", loader: false, action: true, + hasErrorBoundary: false, }, ], }, @@ -14682,6 +14684,7 @@ describe("a router", () => { id: "noLoader", loader: true, action: true, + hasErrorBoundary: false, }, ], }, @@ -14734,12 +14737,14 @@ describe("a router", () => { id: "index", loader: false, action: true, + hasErrorBoundary: false, }, { path: "/foo", id: "foo", loader: false, action: true, + hasErrorBoundary: false, }, ], }, @@ -14793,12 +14798,14 @@ describe("a router", () => { id: "index", loader: false, action: true, + hasErrorBoundary: false, }, { path: "/foo", id: "foo", loader: false, action: true, + hasErrorBoundary: false, }, ], }, @@ -14846,12 +14853,14 @@ describe("a router", () => { { index: true, id: "index", + hasErrorBoundary: false, }, { path: "foo", id: "foo", loader: () => fooDfd.promise, children: undefined, + hasErrorBoundary: false, }, ], }, @@ -14883,11 +14892,13 @@ describe("a router", () => { { index: true, id: "index", + hasErrorBoundary: false, }, { path: "foo", id: "foo", children: undefined, + hasErrorBoundary: false, }, ], }, @@ -14936,12 +14947,14 @@ describe("a router", () => { { index: true, id: "index", + hasErrorBoundary: false, }, { path: "foo", id: "foo", loader: () => fooDfd.promise, children: undefined, + hasErrorBoundary: false, }, ], }, From bbfbf85058a1d1b13de2499d23e880b878403995 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 21 Feb 2023 17:39:17 -0500 Subject: [PATCH 40/64] Remove __DEV__ configs from router lint/jest config --- docs/route/lazy.md | 13 ++++++++++--- docs/route/route.md | 5 ++--- packages/router/.eslintrc | 3 --- packages/router/jest.config.js | 3 --- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/route/lazy.md b/docs/route/lazy.md index 3a4d25e4ab..705c2bef5a 100644 --- a/docs/route/lazy.md +++ b/docs/route/lazy.md @@ -25,8 +25,8 @@ let routes = createRoutesFromElements( Then in your lazy route modules, export the properties you want defined for the route: ```jsx -export function loader({ request }) { - let data = fetchData(request); +export async function loader({ request }) { + let data = await fetchData(request); return json(data); } @@ -44,7 +44,14 @@ function Component() { export const element = ; function ErrorBoundary() { - return

    Something went wrong

    ; + let error = useRouteError(); + return isRouteErrorResponse(error) ? ( +

    + {error.status} {error.statusText} +

    + ) : ( +

    {error.message || error}

    + ); } export const errorElement = ; diff --git a/docs/route/route.md b/docs/route/route.md index 60b1398286..08ebc7dd05 100644 --- a/docs/route/route.md +++ b/docs/route/route.md @@ -346,8 +346,8 @@ let routes = createRoutesFromElements( Then in your lazy route modules, export the properties you want defined for the route: ```jsx -export function loader({ request }) { - let data = fetchData(request); +export async function loader({ request }) { + let data = await fetchData(request); return json(data); } @@ -369,7 +369,6 @@ export const element = ; Please see the [lazy][lazy] documentation for more details. - [outlet]: ./outlet [remix]: https://remix.run [indexroute]: ../start/concepts#index-routes diff --git a/packages/router/.eslintrc b/packages/router/.eslintrc index 7258667c7e..20b88a3051 100644 --- a/packages/router/.eslintrc +++ b/packages/router/.eslintrc @@ -3,9 +3,6 @@ "browser": true, "commonjs": true }, - "globals": { - "__DEV__": true - }, "rules": { "strict": 0, "no-restricted-syntax": ["error", "LogicalExpression[operator='??']"] diff --git a/packages/router/jest.config.js b/packages/router/jest.config.js index 11640c2761..3832dc82eb 100644 --- a/packages/router/jest.config.js +++ b/packages/router/jest.config.js @@ -4,9 +4,6 @@ module.exports = { transform: { "\\.[jt]sx?$": "./jest-transformer.js", }, - globals: { - __DEV__: true, - }, setupFiles: ["./__tests__/setup.ts"], moduleNameMapper: { "^@remix-run/router$": "/index.ts", From 95974d940518d9c4b38a9bda0fda3efe2f988fd3 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 21 Feb 2023 18:31:55 -0500 Subject: [PATCH 41/64] Add decision doc --- decisions/0002-lazy-route-modules.md | 124 +++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 decisions/0002-lazy-route-modules.md diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md new file mode 100644 index 0000000000..71aeb0dfab --- /dev/null +++ b/decisions/0002-lazy-route-modules.md @@ -0,0 +1,124 @@ +# Lazy Route Modules + +Date: 2023-02-21 + +Status: accepted + +## Context + +In a data-aware React Router application (``), the router needs to be aware of the route tree ahead of time so it can match routes and execute loaders/actions _prior_ to rendering the destination route. This is different than in non-data-aware React Router applications (``) where you could nest `` sub-tree anywhere in your application, and compose together `` and `React.lazy()` to dynamically load "new" portions of your routing tree as the user navigated through the application. The downside of this approach in `BrowserRouter` is that it's a render-then-fetch cycle which produces network waterfalls and nested spinners, two things that we're aiming to eliminate in `RouterProvider` applications. + +There were ways to [manually code-split][manually-code-split] in a `RouterProvider` application but they can be a bit verbose and tedious to do manually. As a result of this DX, we received a [Remix Route Modules Proposal][proposal] from community along with a [POC implementation][poc] (thanks `@rossipedia` 🙌). + +## Original POC + +The original POC idea was to implement this in user-land where `element`/`errorElement` would be transformed into `React.Lazy()` calls and `loader`/`action` would load the module and then execute the `loader`/`action`: + +```js +// Assuming route.module is a function returning a Remix-style route module +let Component = React.lazy(route.module); +route.element = ; +route.loader = async (args) => { + const { loader } = await route.module(); + return typeof loader === "function" ? loader(args) : null; +}; +``` + +This approach got us pretty far but suffered from some limitations being done in user-land since it did not have access to some router internals to make for a more seamless integration. Namely, it _had_ to put every possible property onto a route since it couldn't know ahead of time whether the route module would resolve with the matching property. For example, will `import('./route')` return an `errorElement`? Who knows! + +To combat this, a `route.use` property was considered which would allow the user to define the exports of the module: + +```js +const route = { + path: "/", + module: () => import("./route"), + use: ["loader", "element"], +}; +``` + +This wasn't ideal since it introduced a tight coupling of the file contents and the route definitions. + +Furthermore, since the goal of `RouterProvider` is to reduce spinners, it felt incorrect to automatically introduce `React.lazy` and thus expect Suspense boundaries for elements that we expected to be fully fetched _prior_ to rendering the destination route. + +## Decision + +Given what we learned from the original POC, we felt we could do this a bit leaner with an implementation inside the router. Data router apps already have an asynchronous pre-render flow where we could hook in and run this logic. A few advantages of doing this inside of the router include: + +- We can load at a more specific spot internal to the router +- We can access the navigation `AbortSignal` in case the `lazy()` call gets interrupted +- We can also load once and update the internal route definition so subsequent navigations don't have a repeated `lazy()` call +- We don't have issue with knowing whether or not an `errorElement` exists since we will have updated the route prior to updating any UI state + +This proved to work out quite well as we id our own POC so we went with this approach in the end. Now, any time we enter a `submitting`/`loading` state we first check for a `route.lazy` definition and resolve that promise first and update the internal route definition with the result. + +- If an error is thrown by `lazy()` we catch that in the same logic as iof the error was thrown by the action/loader and bubble it to the nearest `errorElement` +- If a `lazy` call is interrupted, we fall into the same interruption handling that actions and loaders already use +- We also restrict which route keys can be updated, preventing users from changing route-matching fields such as `path`/`index`/`children` as those must be defined up front and are considered immutable + +Initially we considered doing an automatic Remix-style-exports mapping so you could export an `ErrorBoundary` from your route file and we'd transform that to `errorElement`, but we chose to avoid that since it's (1) Remix specific and introduces more non-framework-agnostic concepts since `errorElement` isn't actually a field known to the `@remix-run/router` layer. Instead we chose to keep lazy to known route properties and folks are free to define their own mappings in user-land: + +```jsx +function remixStyleExports(loadModule) { + let { loader, default as Component, ErrorBoundary } = await loadModule(); + return { + loader, + element: , + errorElement: , + }; +} + +const routes = [{ + path: '/', + lazy: () => remixStyleExports(() => import("./route")), +}] +``` + +## Consequences + +Not so much as a consequence, but more of limitation - we still require the routing tree up front-for the most efficient data-loading. This means that we can't _yet_ support quite the same nested `` use-cases as before (particularly with respect to microfrontends), but we have ideas for how to solve tht as an extension of this concept in the future. + +Another slightly edge-case concept we discovered is that in DIY SSR applications using `createStaticHandler` and `StaticRouterProvider`, it's possible to server-render a lazy route and send up it's hydration data. But then we may _not_ have those routes loaded in our client-side hydration: + +```jsx +const routes = [{ + path: '/', + lazy: () => import("./route"), +}] +let router = createBrowserRouter(routes, { + hydrationData: window.__hydrationData, +}); + +// ⚠️ What if we're not initialized here! + +ReactDOM.hydrateRoot( + document.getElementById("app")!, + +); +``` + +In the above example, we've server-rendered our `/` route and therefore we _don't_ want to render a `fallbackElement` since we already have the SSR'd content, and the router doesn't need to "initialize" because we've provided the data in `hydrationData`. However, if we're hydrating into a route that includes `lazy`, then we _do_ need to initialize that lazy route. + +The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we need to delay the hydration or our `RouterProvider`: + +```jsx +if (!router.state.initialized) { + let unsub = router.subscribe((state) => { + if (state.initialized) { + unsub(); + hydrate(); + } + }); +} else { + hydrate(); +} +``` + +At the moment this is implemented in a new `ready()` API that we're still deciding if we'll keep or not: + +```js +let router = await createBrowserRouter(routes).ready(); +``` + +[manually-code-split]: https://www.infoxicator.com/en/react-router-6-4-code-splitting +[proposal]: https://github.com/remix-run/react-router/discussions/9826 +[poc]: https://github.com/remix-run/react-router/pull/9830 From ca8685c6327cead085aa3827eb179099406ef9f0 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Feb 2023 11:11:10 -0500 Subject: [PATCH 42/64] Call loader even if lazy() is aborted, like action --- packages/router/__tests__/router-test.ts | 4 ++-- packages/router/router.ts | 25 ++++++++++-------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 88107477d7..362bbe4275 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -11665,7 +11665,7 @@ describe("a router", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("idle"); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); // Ensure the lazy route object update still happened let lazyRoute = findRouteById(t.router.routes, "lazy"); @@ -12130,7 +12130,7 @@ describe("a router", () => { expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER B"); expect(lazyLoaderStubA).not.toHaveBeenCalled(); - expect(lazyLoaderStubB).toHaveBeenCalledTimes(1); + expect(lazyLoaderStubB).toHaveBeenCalledTimes(2); }); it("handles loader errors in lazy route modules on fetcher.load", async () => { diff --git a/packages/router/router.ts b/packages/router/router.ts index e36716d788..83d2047ba4 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3344,21 +3344,16 @@ async function callLoaderOrAction( onReject = () => reject(); request.signal.addEventListener("abort", onReject); - if (!request.signal.aborted || type === "action") { - // Still kick off actions if we got interrupted to maintain consistency - // with un-abortable behavior of action execution on non-lazy routes - result = await Promise.race([ - handler({ - request, - params: match.params, - context: requestContext, - }), - abortPromise, - ]); - } else { - // No need to run loaders if we got aborted during lazy() - result = await abortPromise; - } + // Still kick off handlers if we got interrupted to maintain consistency + // with un-abortable behavior of handler execution on non-lazy routes + result = await Promise.race([ + handler({ + request, + params: match.params, + context: requestContext, + }), + abortPromise, + ]); invariant( result !== undefined, From c8217843699c4a2ac872cc27c907cab6025fefef Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Feb 2023 11:11:27 -0500 Subject: [PATCH 43/64] Update decision doc --- decisions/0002-lazy-route-modules.md | 148 ++++++++++++++++++++++++--- 1 file changed, 131 insertions(+), 17 deletions(-) diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md index 71aeb0dfab..3055637641 100644 --- a/decisions/0002-lazy-route-modules.md +++ b/decisions/0002-lazy-route-modules.md @@ -49,30 +49,140 @@ Given what we learned from the original POC, we felt we could do this a bit lean - We can also load once and update the internal route definition so subsequent navigations don't have a repeated `lazy()` call - We don't have issue with knowing whether or not an `errorElement` exists since we will have updated the route prior to updating any UI state -This proved to work out quite well as we id our own POC so we went with this approach in the end. Now, any time we enter a `submitting`/`loading` state we first check for a `route.lazy` definition and resolve that promise first and update the internal route definition with the result. +This proved to work out quite well as we did our own POC so we went with this approach in the end. Now, any time we enter a `submitting`/`loading` state we first check for a `route.lazy` definition and resolve that promise first and update the internal route definition with the result. -- If an error is thrown by `lazy()` we catch that in the same logic as iof the error was thrown by the action/loader and bubble it to the nearest `errorElement` -- If a `lazy` call is interrupted, we fall into the same interruption handling that actions and loaders already use -- We also restrict which route keys can be updated, preventing users from changing route-matching fields such as `path`/`index`/`children` as those must be defined up front and are considered immutable +The resulting API looks like this, assuming you want to load your homepage in the main bundle, but lazily load the code for the `/about` route: -Initially we considered doing an automatic Remix-style-exports mapping so you could export an `ErrorBoundary` from your route file and we'd transform that to `errorElement`, but we chose to avoid that since it's (1) Remix specific and introduces more non-framework-agnostic concepts since `errorElement` isn't actually a field known to the `@remix-run/router` layer. Instead we chose to keep lazy to known route properties and folks are free to define their own mappings in user-land: +```jsx +// app.jsx +const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { + index: true, + element: , + }, + { + path: "about", + lazy: () => import("./about"), + }, + ], + }, +]); +``` + +And then your `about.jsx` file would export the properties to be lazily defined on the route: ```jsx -function remixStyleExports(loadModule) { - let { loader, default as Component, ErrorBoundary } = await loadModule(); - return { - loader, - element: , - errorElement: , - }; -} +// about.jsx +export function loader() { ... } -const routes = [{ - path: '/', - lazy: () => remixStyleExports(() => import("./route")), -}] +export const element = + +function Component() { ... } +``` + +## Choices + +Here's a few choices we made along the way: + +### Static Route Properties + +A route has 3 types of fields defined on it: + +- Path matching fields: `path`, `index`, `caseSensitive` and `children` + - While not strictly used for matching, `id` is also considered static since it is needed up-front to uniquely identify all defined routes +- Data loading fields: `loader`, `action`, `hasErrorBoundary`, `shouldRevalidate` +- Rendering fields: `handle` and the framework-aware `element`/`errorElement` + +The `route.lazy()` method is focused on lazy-loading the data loading and rendering fields, but cannot update the path matching fields because we have to path match _first_ before we can even identify which matched routes include a `lazy()` function. Therefore, we do not allow path matching route keys to be updated by `lazy()`, and will log a warning if you return one of those fields from your lazy() method. + +### Addition of route `Component` and `ErrorBoundary` fields + +In React Router v6, routes define `element` properties because it allows static prop passing as well as fitting nicely in the JSX render-tree-defined route trees: + +```jsx + + + } /> + + +``` + +However, in a React Router 6.4+ landscape when using `RouterProvider`, routes are defined statically up-front to enable data-loading, so using element feels arguably a bit awkward outside of a JSX tree: + +```js +const routes = [ + { + path: "/", + element: , + }, +]; ``` +It also means that you cannot easily use hooks inline, and have to add a level of indirection to access hooks. + +This gets a bit more awkward with the introduction of `lazy()` since your file now has to export a root-level JSX element: + +```jsx +// home.jsx +export const element = + +function Homepage() { ... } +``` + +In reality, what we want in this "static route definition" landscape is just the component for the Route: + +```js +const routes = [ + { + path: "/", + Component: Homepage, + }, +]; +``` + +This has a number of advantages in that we can now use inline component functions to access hooks, provide props, etc. And we also simplify the exports of a `lazy()` route module: + +```jsx +const routes = [ + { + path: "/", + // You can include just the component + Component: Homepage, + }, + { + path: "/a", + // Or you can inline your component and pass props + Component: () => , + }, + { + path: "/b", + // And even use use hooks without indirection 💥 + Component: () => { + let data = useLoaderData(); + return ; + }, + }, +]; +``` + +So in the end, the work for `lazy()` introduced support for `route.Component` and `route.ErrorBoundary`, which can be statically or lazily defined. `element`/`errorElement` will be considered deprecated in data routers and may go away in version 7. + +### Interruptions + +Previously when a link was clicked or a form was submitted, since we had the `action`/`loader` defined statically up-front, they were immediately executed and therew was no chance for an interruption _before calling the handler_. Now that we've introduced the concept of `lazy()` there is a period of time prior to executing the handler where the user could interrupt the navigation by clicking to a new location. In order to keep behavior consistent with lazily-loaded routes and statically defined routes, if a `lazy()` function is interrupted React Router _will still call the returned handler_. As always, the user can leverage `request.signal.aborted` inside the handler to short-circuit on interruption if desired. + +This is important because `lazy()` is only ever run once in an application session. Once lazy has completed it updates the route in place, and all subsequent navigations to that route use the now-statically-defined properties. Without this behavior, routes would behave differently on the _first_ navigation versus _subsequent_ navigations which could introduce subtle and hard-to-track-down bugs. + +Additionally, since `lazy()` functions are intended to return a static definition of route `loader`/`element`/etc. - if multiple navigations happen to the same route in parallel, the first `lazy()` call to resolve will "win" and update the route, and the returned values from any other `lazy()` executions will be ignored. This should not be much of an issue in practice though as modern bundlers latch onto the same promise for repeated calls to `import()` so in those cases the first call will still "win". + +### Error Handling + +If an error is thrown by `lazy()` we catch that in the same logic as iof the error was thrown by the `action`/`loader` and bubble it to the nearest `errorElement`. + ## Consequences Not so much as a consequence, but more of limitation - we still require the routing tree up front-for the most efficient data-loading. This means that we can't _yet_ support quite the same nested `` use-cases as before (particularly with respect to microfrontends), but we have ideas for how to solve tht as an extension of this concept in the future. @@ -119,6 +229,10 @@ At the moment this is implemented in a new `ready()` API that we're still decidi let router = await createBrowserRouter(routes).ready(); ``` +## Future Optimizations + +Right now, `lazy()` and `loader()` execution are called sequentially _even if the loader is statically defined_. Eventually we will likely detect the statically-defined `loader` and call it in parallel with `lazy` (since lazy wil be unable to update the loader anyway!). This will provide the ability to obtain the most-optimal parallelization of loading your component in parallel with your loader fetches. + [manually-code-split]: https://www.infoxicator.com/en/react-router-6-4-code-splitting [proposal]: https://github.com/remix-run/react-router/discussions/9826 [poc]: https://github.com/remix-run/react-router/pull/9830 From 664464a8dcb0c15a877d9481f7b138bfbb7fb5f4 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Feb 2023 11:30:16 -0500 Subject: [PATCH 44/64] Update comments and re-organize tests --- packages/router/__tests__/router-test.ts | 1652 ++++++++++++---------- packages/router/router.ts | 23 +- 2 files changed, 879 insertions(+), 796 deletions(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 362bbe4275..76309bf8b2 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -11564,965 +11564,1049 @@ describe("a router", () => { }, ]; - it("fetches lazy route modules on loading navigation", async () => { - let t = setup({ routes: LAZY_ROUTES }); + describe("happy path", () => { + it("fetches lazy route modules on loading navigation", async () => { + let t = setup({ routes: LAZY_ROUTES }); - let A = await t.navigate("/lazy"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - let dfd = createDeferred(); - A.lazy.lazy.resolve({ - loader: () => dfd.promise, - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + let dfd = createDeferred(); + A.lazy.lazy.resolve({ + loader: () => dfd.promise, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - await dfd.resolve("LAZY LOADER"); + await dfd.resolve("LAZY LOADER"); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.loaderData).toEqual({ - lazy: "LAZY LOADER", + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.loaderData).toEqual({ + lazy: "LAZY LOADER", + }); }); - }); - it("handles errors when failing to load lazy route modules on loading navigation", async () => { - let t = setup({ routes: LAZY_ROUTES }); + it("fetches lazy route modules on submission navigation", async () => { + let t = setup({ routes: LAZY_ROUTES }); - let A = await t.navigate("/lazy"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); + let actionDfd = createDeferred(); + let loaderDfd = createDeferred(); + A.lazy.lazy.resolve({ + action: () => actionDfd.promise, + loader: () => loaderDfd.promise, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - expect(t.router.state.loaderData).toEqual({}); - expect(t.router.state.errors).toEqual({ - root: new Error("LAZY FUNCTION ERROR"), - }); - }); + await actionDfd.resolve("LAZY ACTION"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(t.router.state.actionData).toEqual({ + lazy: "LAZY ACTION", + }); + expect(t.router.state.loaderData).toEqual({}); - it("keeps existing loader on loading navigation if static loader is already defined", async () => { - let consoleWarn = jest.spyOn(console, "warn"); - let t = setup({ - routes: [ - { - id: "lazy", - path: "/lazy", - loader: true, - lazy: true, - }, - ], + await loaderDfd.resolve("LAZY LOADER"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual({ + lazy: "LAZY ACTION", + }); + expect(t.router.state.loaderData).toEqual({ + lazy: "LAZY LOADER", + }); }); - let A = await t.navigate("/lazy"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + it("fetches lazy route modules on fetcher.load", async () => { + let t = setup({ routes: LAZY_ROUTES }); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await A.lazy.lazy.resolve({ - loader: lazyLoaderStub, - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + let key = "key"; + let A = await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - await A.loaders.lazy.resolve("STATIC LOADER"); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.loaderData).toEqual({ - lazy: "STATIC LOADER", + let loaderDfd = createDeferred(); + await A.lazy.lazy.resolve({ + loader: () => loaderDfd.promise, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await loaderDfd.resolve("LAZY LOADER"); + expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER"); }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.loader).not.toBe(lazyLoaderStub); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + it("fetches lazy route modules on fetcher.submit", async () => { + let t = setup({ routes: LAZY_ROUTES }); - expect(consoleWarn).toHaveBeenCalledTimes(1); - expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( - `"Route "lazy" has a static property "loader" defined but its lazy function is also returning a value for this property. The lazy route property "loader" will be ignored."` - ); - consoleWarn.mockReset(); - }); + let key = "key"; + let A = await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - it("fetches lazy route modules on loading navigation when navigating away before lazy promise resolves", async () => { - let t = setup({ routes: LAZY_ROUTES }); + let actionDfd = createDeferred(); + await A.lazy.lazy.resolve({ + action: () => actionDfd.promise, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - let A = await t.navigate("/lazy"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + await actionDfd.resolve("LAZY ACTION"); + expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY ACTION"); + }); - await t.navigate("/"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("idle"); + it("fetches lazy route modules on staticHandler.query()", async () => { + let { query } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + lazy: async () => { + await tick(); + return { + async loader() { + return json({ value: "LAZY LOADER" }); + }, + }; + }, + }, + ]); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await A.lazy.lazy.resolve({ - loader: lazyLoaderStub, + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ lazy: { value: "LAZY LOADER" } }); }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - // Ensure the lazy route object update still happened - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.loader).toBe(lazyLoaderStub); + it("fetches lazy route modules on staticHandler.queryRoute()", async () => { + let { queryRoute } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + lazy: async () => { + await tick(); + return { + async loader() { + return json({ value: "LAZY LOADER" }); + }, + }; + }, + }, + ]); + + let response = await queryRoute(createRequest("/lazy")); + let data = await response.json(); + expect(data).toEqual({ value: "LAZY LOADER" }); + }); }); - it("handles loader errors in lazy route modules on loading navigation", async () => { - let t = setup({ routes: LAZY_ROUTES }); + describe("statically defined fields", () => { + it("prefers statically defined loader over lazily defined loader", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let t = setup({ + routes: [ + { + id: "lazy", + path: "/lazy", + loader: true, + lazy: true, + }, + ], + }); + + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - let A = await t.navigate("/lazy"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); + await A.lazy.lazy.resolve({ + loader: lazyLoaderStub, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - let dfd = createDeferred(); - A.lazy.lazy.resolve({ - loader: () => dfd.promise, - hasErrorBoundary: true, - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + await A.loaders.lazy.resolve("STATIC LOADER"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.loaderData).toEqual({ + lazy: "STATIC LOADER", + }); - await dfd.reject(new Error("LAZY LOADER ERROR")); + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.loader).toEqual(expect.any(Function)); + expect(lazyRoute.loader).not.toBe(lazyLoaderStub); + expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.errors).toEqual({ - lazy: new Error("LAZY LOADER ERROR"), + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "loader" defined but its lazy function is also returning a value for this property. The lazy route property "loader" will be ignored."` + ); + consoleWarn.mockReset(); }); - }); - - it("handles bubbling of loader errors in lazy route modules on loading navigation when hasErrorBoundary is not defined", async () => { - let t = setup({ routes: LAZY_ROUTES }); - let A = await t.navigate("/lazy"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + it("prefers statically defined action over lazily loaded action", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let t = setup({ + routes: [ + { + id: "lazy", + path: "/lazy", + action: true, + lazy: true, + }, + ], + }); - let dfd = createDeferred(); - A.lazy.lazy.resolve({ - loader: () => dfd.promise, - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - await dfd.reject(new Error("LAZY LOADER ERROR")); + let lazyActionStub = jest.fn(() => "LAZY ACTION"); + let loaderDfd = createDeferred(); + await A.lazy.lazy.resolve({ + action: lazyActionStub, + loader: () => loaderDfd.promise, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.errors).toEqual({ - root: new Error("LAZY LOADER ERROR"), - }); - }); + await A.actions.lazy.resolve("STATIC ACTION"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(t.router.state.actionData).toEqual({ + lazy: "STATIC ACTION", + }); + expect(t.router.state.loaderData).toEqual({}); - it("handles bubbling of loader errors in lazy route modules on loading navigation when hasErrorBoundary is resolved as false", async () => { - let t = setup({ routes: LAZY_ROUTES }); + await loaderDfd.resolve("LAZY LOADER"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual({ + lazy: "STATIC ACTION", + }); + expect(t.router.state.loaderData).toEqual({ + lazy: "LAZY LOADER", + }); - let A = await t.navigate("/lazy"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.action).toEqual(expect.any(Function)); + expect(lazyRoute.action).not.toBe(lazyActionStub); + expect(lazyActionStub).not.toHaveBeenCalled(); - let dfd = createDeferred(); - A.lazy.lazy.resolve({ - loader: () => dfd.promise, - hasErrorBoundary: false, + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "action" defined but its lazy function is also returning a value for this property. The lazy route property "action" will be ignored."` + ); + consoleWarn.mockReset(); }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); - await dfd.reject(new Error("LAZY LOADER ERROR")); + it("prefers statically defined action/loader over lazily defined action/loader", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let t = setup({ + routes: [ + { + id: "lazy", + path: "/lazy", + action: true, + loader: true, + lazy: true, + }, + ], + }); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.errors).toEqual({ - root: new Error("LAZY LOADER ERROR"), - }); - }); + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - it("fetches lazy route modules on submission navigation", async () => { - let t = setup({ routes: LAZY_ROUTES }); + let lazyActionStub = jest.fn(() => "LAZY ACTION"); + let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); + await A.lazy.lazy.resolve({ + action: lazyActionStub, + loader: lazyLoaderStub, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - let A = await t.navigate("/lazy", { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + await A.actions.lazy.resolve("STATIC ACTION"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(t.router.state.actionData).toEqual({ + lazy: "STATIC ACTION", + }); + expect(t.router.state.loaderData).toEqual({}); - let actionDfd = createDeferred(); - let loaderDfd = createDeferred(); - A.lazy.lazy.resolve({ - action: () => actionDfd.promise, - loader: () => loaderDfd.promise, - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + await A.loaders.lazy.resolve("STATIC LOADER"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual({ + lazy: "STATIC ACTION", + }); + expect(t.router.state.loaderData).toEqual({ + lazy: "STATIC LOADER", + }); - await actionDfd.resolve("LAZY ACTION"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); - expect(t.router.state.actionData).toEqual({ - lazy: "LAZY ACTION", - }); - expect(t.router.state.loaderData).toEqual({}); + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.action).toEqual(expect.any(Function)); + expect(lazyRoute.loader).toEqual(expect.any(Function)); + expect(lazyRoute.action).not.toBe(lazyActionStub); + expect(lazyRoute.loader).not.toBe(lazyLoaderStub); + expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyLoaderStub).not.toHaveBeenCalled(); - await loaderDfd.resolve("LAZY LOADER"); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.actionData).toEqual({ - lazy: "LAZY ACTION", - }); - expect(t.router.state.loaderData).toEqual({ - lazy: "LAZY LOADER", + expect(consoleWarn).toHaveBeenCalledTimes(2); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "action" defined but its lazy function is also returning a value for this property. The lazy route property "action" will be ignored."` + ); + expect(consoleWarn.mock.calls[1][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "loader" defined but its lazy function is also returning a value for this property. The lazy route property "loader" will be ignored."` + ); + consoleWarn.mockReset(); }); - }); - it("handles errors when failing to load lazy route modules on submission navigation", async () => { - let t = setup({ routes: LAZY_ROUTES }); + it("prefers statically defined loader over lazily defined loader (staticHandler.query)", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let lazyLoaderStub = jest.fn(async () => { + await tick(); + return json({ value: "LAZY LOADER" }); + }); - let A = await t.navigate("/lazy", { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + let { query } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + loader: async () => { + await tick(); + return json({ value: "STATIC LOADER" }); + }, + lazy: async () => { + await tick(); + return { + loader: lazyLoaderStub, + }; + }, + }, + ]); - await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ + lazy: { value: "STATIC LOADER" }, + }); + expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(t.router.state.errors).toEqual({ - root: new Error("LAZY FUNCTION ERROR"), + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "loader" defined but its lazy function is also returning a value for this property. The lazy route property "loader" will be ignored."` + ); + consoleWarn.mockReset(); }); - expect(t.router.state.actionData).toEqual(null); - expect(t.router.state.loaderData).toEqual({}); - }); - it("keeps existing action on submission navigation if static action is already defined", async () => { - let consoleWarn = jest.spyOn(console, "warn"); - let t = setup({ - routes: [ + it("prefers statically defined loader over lazily defined loader (staticHandler.queryRoute)", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let lazyLoaderStub = jest.fn(async () => { + await tick(); + return json({ value: "LAZY LOADER" }); + }); + + let { query } = createStaticHandler([ { id: "lazy", path: "/lazy", - action: true, - lazy: true, + loader: async () => { + await tick(); + return json({ value: "STATIC LOADER" }); + }, + lazy: async () => { + await tick(); + return { + loader: lazyLoaderStub, + }; + }, }, - ], - }); + ]); - let A = await t.navigate("/lazy", { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ + lazy: { value: "STATIC LOADER" }, + }); + expect(lazyLoaderStub).not.toHaveBeenCalled(); - let lazyActionStub = jest.fn(() => "LAZY ACTION"); - let loaderDfd = createDeferred(); - await A.lazy.lazy.resolve({ - action: lazyActionStub, - loader: () => loaderDfd.promise, + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "loader" defined but its lazy function is also returning a value for this property. The lazy route property "loader" will be ignored."` + ); + consoleWarn.mockReset(); }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + }); - await A.actions.lazy.resolve("STATIC ACTION"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); - expect(t.router.state.actionData).toEqual({ - lazy: "STATIC ACTION", - }); - expect(t.router.state.loaderData).toEqual({}); + describe("interruptions", () => { + it("runs lazily loaded route loader even if lazy() is interrupted", async () => { + let t = setup({ routes: LAZY_ROUTES }); - await loaderDfd.resolve("LAZY LOADER"); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.actionData).toEqual({ - lazy: "STATIC ACTION", - }); - expect(t.router.state.loaderData).toEqual({ - lazy: "LAZY LOADER", - }); + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(lazyActionStub); - expect(lazyActionStub).not.toHaveBeenCalled(); + await t.navigate("/"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("idle"); - expect(consoleWarn).toHaveBeenCalledTimes(1); - expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( - `"Route "lazy" has a static property "action" defined but its lazy function is also returning a value for this property. The lazy route property "action" will be ignored."` - ); - consoleWarn.mockReset(); - }); + let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); + await A.lazy.lazy.resolve({ + loader: lazyLoaderStub, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); - it("keeps existing action and loader on submission navigation if static action and loader are already defined", async () => { - let consoleWarn = jest.spyOn(console, "warn"); - let t = setup({ - routes: [ - { - id: "lazy", - path: "/lazy", - action: true, - loader: true, - lazy: true, - }, - ], + // Ensure the lazy route object update still happened + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.loader).toBe(lazyLoaderStub); }); - let A = await t.navigate("/lazy", { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + it("runs lazily loaded route action even if lazy() is interrupted", async () => { + let t = setup({ routes: LAZY_ROUTES }); - let lazyActionStub = jest.fn(() => "LAZY ACTION"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await A.lazy.lazy.resolve({ - action: lazyActionStub, - loader: lazyLoaderStub, - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - await A.actions.lazy.resolve("STATIC ACTION"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("loading"); - expect(t.router.state.actionData).toEqual({ - lazy: "STATIC ACTION", - }); - expect(t.router.state.loaderData).toEqual({}); + await t.navigate("/"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("idle"); - await A.loaders.lazy.resolve("STATIC LOADER"); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.actionData).toEqual({ - lazy: "STATIC ACTION", - }); - expect(t.router.state.loaderData).toEqual({ - lazy: "STATIC LOADER", + let lazyActionStub = jest.fn(() => "LAZY ACTION"); + let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); + await A.lazy.lazy.resolve({ + action: lazyActionStub, + loader: lazyLoaderStub, + }); + + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.action).toBe(lazyActionStub); + expect(lazyRoute.loader).toBe(lazyLoaderStub); }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toEqual(expect.any(Function)); - expect(lazyRoute.loader).toEqual(expect.any(Function)); - expect(lazyRoute.action).not.toBe(lazyActionStub); - expect(lazyRoute.loader).not.toBe(lazyLoaderStub); - expect(lazyActionStub).not.toHaveBeenCalled(); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + it("runs lazily loaded route loader on fetcher.load() even if lazy() is interrupted", async () => { + let t = setup({ routes: LAZY_ROUTES }); - expect(consoleWarn).toHaveBeenCalledTimes(2); - expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( - `"Route "lazy" has a static property "action" defined but its lazy function is also returning a value for this property. The lazy route property "action" will be ignored."` - ); - expect(consoleWarn.mock.calls[1][0]).toMatchInlineSnapshot( - `"Route "lazy" has a static property "loader" defined but its lazy function is also returning a value for this property. The lazy route property "loader" will be ignored."` - ); - consoleWarn.mockReset(); - }); + let key = "key"; + let A = await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - it("fetches lazy route modules and allows action to run on submission navigation when navigating away before lazy promise resolves", async () => { - let t = setup({ routes: LAZY_ROUTES }); + let B = await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - let A = await t.navigate("/lazy", { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + // Resolve B's lazy route first + let loaderDfdB = createDeferred(); + let lazyloaderStubB = jest.fn(() => loaderDfdB.promise); + await B.lazy.lazy.resolve({ + loader: lazyloaderStubB, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - await t.navigate("/"); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("idle"); + // Resolve A's lazy route after B + let loaderDfdA = createDeferred(); + let lazyLoaderStubA = jest.fn(() => loaderDfdA.promise); + await A.lazy.lazy.resolve({ + loader: lazyLoaderStubA, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await loaderDfdA.resolve("LAZY LOADER A"); + await loaderDfdB.resolve("LAZY LOADER B"); - let lazyActionStub = jest.fn(() => "LAZY ACTION"); - let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); - await A.lazy.lazy.resolve({ - action: lazyActionStub, - loader: lazyLoaderStub, + expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER B"); + expect(lazyLoaderStubA).not.toHaveBeenCalled(); + expect(lazyloaderStubB).toHaveBeenCalledTimes(2); }); - let lazyRoute = findRouteById(t.router.routes, "lazy"); - expect(lazyActionStub).toHaveBeenCalledTimes(1); - expect(lazyLoaderStub).not.toHaveBeenCalled(); - expect(lazyRoute.lazy).toBeUndefined(); - expect(lazyRoute.action).toBe(lazyActionStub); - expect(lazyRoute.loader).toBe(lazyLoaderStub); - }); + it("runs lazily loaded route action on fetcher.submit() even if lazy() is interrupted", async () => { + let t = setup({ routes: LAZY_ROUTES }); - it("fetches lazy route modules and allows action to run on submission navigation and keeps the first resolved value when submitting multiple times", async () => { - let t = setup({ routes: LAZY_ROUTES }); + let key = "key"; + let A = await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - let A = await t.navigate("/lazy", { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + let B = await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - let B = await t.navigate("/lazy", { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + // Resolve B's lazy route first + let actionDfdB = createDeferred(); + let lazyActionStubB = jest.fn(() => actionDfdB.promise); + await B.lazy.lazy.resolve({ + action: lazyActionStubB, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - // Resolve B's lazy route first - let loaderDfdB = createDeferred(); - let actionDfdB = createDeferred(); - let lazyLoaderStubB = jest.fn(() => loaderDfdB.promise); - let lazyActionStubB = jest.fn(() => actionDfdB.promise); - await B.lazy.lazy.resolve({ - action: lazyActionStubB, - loader: lazyLoaderStubB, - }); + // Resolve A's lazy route after B + let actionDfdA = createDeferred(); + let lazyActionStubA = jest.fn(() => actionDfdA.promise); + await A.lazy.lazy.resolve({ + action: lazyActionStubA, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - // Resolve A's lazy route after B - let loaderDfdA = createDeferred(); - let actionDfdA = createDeferred(); - let lazyLoaderStubA = jest.fn(() => loaderDfdA.promise); - let lazyActionStubA = jest.fn(() => actionDfdA.promise); - await A.lazy.lazy.resolve({ - action: lazyActionStubA, - loader: lazyLoaderStubA, + await actionDfdA.resolve("LAZY ACTION A"); + await actionDfdB.resolve("LAZY ACTION B"); + + expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY ACTION B"); + expect(lazyActionStubA).not.toHaveBeenCalled(); + expect(lazyActionStubB).toHaveBeenCalledTimes(2); }); - await actionDfdA.resolve("LAZY ACTION A"); - await loaderDfdA.resolve("LAZY LOADER A"); - await actionDfdB.resolve("LAZY ACTION B"); - await loaderDfdB.resolve("LAZY LOADER B"); + it("uses the first-resolved lazy() execution on repeated loading navigations", async () => { + let t = setup({ routes: LAZY_ROUTES }); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - expect(t.router.state.actionData).toEqual({ lazy: "LAZY ACTION B" }); - expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER B" }); + let B = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - expect(lazyActionStubA).not.toHaveBeenCalled(); - expect(lazyLoaderStubA).not.toHaveBeenCalled(); - expect(lazyActionStubB).toHaveBeenCalledTimes(2); - expect(lazyLoaderStubB).toHaveBeenCalledTimes(1); - }); + // Resolve B's lazy route first + let loaderDfdB = createDeferred(); + let lazyLoaderStubB = jest.fn(() => loaderDfdB.promise); + await B.lazy.lazy.resolve({ + loader: lazyLoaderStubB, + }); - it("handles action errors in lazy route modules on submission navigation", async () => { - let t = setup({ routes: LAZY_ROUTES }); + // Resolve A's lazy route after B + let loaderDfdA = createDeferred(); + let lazyLoaderStubA = jest.fn(() => loaderDfdA.promise); + await A.lazy.lazy.resolve({ + loader: lazyLoaderStubA, + }); - let A = await t.navigate("/lazy", { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + await loaderDfdA.resolve("LAZY LOADER A"); + await loaderDfdB.resolve("LAZY LOADER B"); - let actionDfd = createDeferred(); - A.lazy.lazy.resolve({ - action: () => actionDfd.promise, - hasErrorBoundary: true, - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); - await actionDfd.reject(new Error("LAZY ACTION ERROR")); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.actionData).toEqual(null); - expect(t.router.state.errors).toEqual({ - lazy: new Error("LAZY ACTION ERROR"), + expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER B" }); + + expect(lazyLoaderStubA).not.toHaveBeenCalled(); + expect(lazyLoaderStubB).toHaveBeenCalledTimes(2); }); - }); - it("handles bubbling of action errors in lazy route modules on submission navigation when hasErrorBoundary is resolved as false", async () => { - let t = setup({ routes: LAZY_ROUTES }); + it("uses the first-resolved lazy() execution on repeated submission navigations", async () => { + let t = setup({ routes: LAZY_ROUTES }); - let A = await t.navigate("/lazy", { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - let actionDfd = createDeferred(); - A.lazy.lazy.resolve({ - action: () => actionDfd.promise, - hasErrorBoundary: false, - }); - expect(t.router.state.location.pathname).toBe("/"); - expect(t.router.state.navigation.state).toBe("submitting"); + let B = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - await actionDfd.reject(new Error("LAZY ACTION ERROR")); - expect(t.router.state.location.pathname).toBe("/lazy"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.actionData).toEqual(null); - expect(t.router.state.errors).toEqual({ - root: new Error("LAZY ACTION ERROR"), - }); - }); + // Resolve B's lazy route first + let loaderDfdB = createDeferred(); + let actionDfdB = createDeferred(); + let lazyLoaderStubB = jest.fn(() => loaderDfdB.promise); + let lazyActionStubB = jest.fn(() => actionDfdB.promise); + await B.lazy.lazy.resolve({ + action: lazyActionStubB, + loader: lazyLoaderStubB, + }); - it("fetches lazy route modules on fetcher.load", async () => { - let t = setup({ routes: LAZY_ROUTES }); + // Resolve A's lazy route after B + let loaderDfdA = createDeferred(); + let actionDfdA = createDeferred(); + let lazyLoaderStubA = jest.fn(() => loaderDfdA.promise); + let lazyActionStubA = jest.fn(() => actionDfdA.promise); + await A.lazy.lazy.resolve({ + action: lazyActionStubA, + loader: lazyLoaderStubA, + }); - let key = "key"; - let A = await t.fetch("/lazy", key); - expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + await actionDfdA.resolve("LAZY ACTION A"); + await loaderDfdA.resolve("LAZY LOADER A"); + await actionDfdB.resolve("LAZY ACTION B"); + await loaderDfdB.resolve("LAZY LOADER B"); - let loaderDfd = createDeferred(); - await A.lazy.lazy.resolve({ - loader: () => loaderDfd.promise, + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + + expect(t.router.state.actionData).toEqual({ lazy: "LAZY ACTION B" }); + expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER B" }); + + expect(lazyActionStubA).not.toHaveBeenCalled(); + expect(lazyLoaderStubA).not.toHaveBeenCalled(); + expect(lazyActionStubB).toHaveBeenCalledTimes(2); + expect(lazyLoaderStubB).toHaveBeenCalledTimes(1); }); - expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - await loaderDfd.resolve("LAZY LOADER"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER"); - }); + it("uses the first-resolved lazy() execution on repeated fetcher.load calls", async () => { + let t = setup({ routes: LAZY_ROUTES }); - it("handles errors when failing to load lazy route modules on fetcher.load", async () => { - let t = setup({ routes: LAZY_ROUTES }); + let key = "key"; + let A = await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - let key = "key"; - let A = await t.fetch("/lazy", key); - expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + let B = await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); - expect(t.router.state.fetchers.get(key)).toBeUndefined(); - expect(t.router.state.errors).toEqual({ - root: new Error("LAZY FUNCTION ERROR"), + // Resolve B's lazy route first + let loaderDfdB = createDeferred(); + let lazyLoaderStubB = jest.fn(() => loaderDfdB.promise); + await B.lazy.lazy.resolve({ + loader: lazyLoaderStubB, + }); + + // Resolve A's lazy route after B + let loaderDfdA = createDeferred(); + let lazyLoaderStubA = jest.fn(() => loaderDfdA.promise); + await A.lazy.lazy.resolve({ + loader: lazyLoaderStubA, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await loaderDfdA.resolve("LAZY LOADER A"); + await loaderDfdB.resolve("LAZY LOADER B"); + + expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); + expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER B"); + expect(lazyLoaderStubA).not.toHaveBeenCalled(); + expect(lazyLoaderStubB).toHaveBeenCalledTimes(2); }); }); - it("fetches lazy route modules on fetcher.load and stores the first resolved value if fetcher is called multiple times", async () => { - let t = setup({ routes: LAZY_ROUTES }); + describe("errors", () => { + it("handles errors when failing to load lazy route modules on loading navigation", async () => { + let t = setup({ routes: LAZY_ROUTES }); - let key = "key"; - let A = await t.fetch("/lazy", key); - expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - - let B = await t.fetch("/lazy", key); - expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - // Resolve B's lazy route first - let loaderDfdB = createDeferred(); - let lazyLoaderStubB = jest.fn(() => loaderDfdB.promise); - await B.lazy.lazy.resolve({ - loader: lazyLoaderStubB, - }); + await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); - // Resolve A's lazy route after B - let loaderDfdA = createDeferred(); - let lazyLoaderStubA = jest.fn(() => loaderDfdA.promise); - await A.lazy.lazy.resolve({ - loader: lazyLoaderStubA, + expect(t.router.state.loaderData).toEqual({}); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); }); - expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - await loaderDfdA.resolve("LAZY LOADER A"); - await loaderDfdB.resolve("LAZY LOADER B"); + it("handles loader errors from lazy route modules when the route has an error boundary", async () => { + let t = setup({ routes: LAZY_ROUTES }); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY LOADER B"); - expect(lazyLoaderStubA).not.toHaveBeenCalled(); - expect(lazyLoaderStubB).toHaveBeenCalledTimes(2); - }); + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - it("handles loader errors in lazy route modules on fetcher.load", async () => { - let t = setup({ routes: LAZY_ROUTES }); + let dfd = createDeferred(); + A.lazy.lazy.resolve({ + loader: () => dfd.promise, + hasErrorBoundary: true, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - let key = "key"; - let A = await t.fetch("/lazy", key); - expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + await dfd.reject(new Error("LAZY LOADER ERROR")); - let loaderDfd = createDeferred(); - await A.lazy.lazy.resolve({ - loader: () => loaderDfd.promise, + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.errors).toEqual({ + lazy: new Error("LAZY LOADER ERROR"), + }); }); - expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - await loaderDfd.reject(new Error("LAZY LOADER ERROR")); - expect(t.router.state.fetchers.get(key)).toBeUndefined(); - expect(t.router.state.errors).toEqual({ - root: new Error("LAZY LOADER ERROR"), - }); - }); + it("bubbles loader errors from in lazy route modules when the route does not specify an error boundary", async () => { + let t = setup({ routes: LAZY_ROUTES }); + + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - it("fetches lazy route modules on fetcher.submit", async () => { - let t = setup({ routes: LAZY_ROUTES }); + let dfd = createDeferred(); + A.lazy.lazy.resolve({ + loader: () => dfd.promise, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - let key = "key"; - let A = await t.fetch("/lazy", key, { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + await dfd.reject(new Error("LAZY LOADER ERROR")); - let actionDfd = createDeferred(); - await A.lazy.lazy.resolve({ - action: () => actionDfd.promise, + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - await actionDfd.resolve("LAZY ACTION"); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY ACTION"); - }); + it("bubbles loader errors from lazy route modules when the route specifies hasErrorBoundary:false", async () => { + let t = setup({ routes: LAZY_ROUTES }); - it("handles errors when failing to load lazy route modules on fetcher.submit", async () => { - let t = setup({ routes: LAZY_ROUTES }); + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - let key = "key"; - let A = await t.fetch("/lazy", key, { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + let dfd = createDeferred(); + A.lazy.lazy.resolve({ + loader: () => dfd.promise, + hasErrorBoundary: false, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); - await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); - expect(t.router.state.fetchers.get(key)).toBeUndefined(); - expect(t.router.state.errors).toEqual({ - root: new Error("LAZY FUNCTION ERROR"), + await dfd.reject(new Error("LAZY LOADER ERROR")); + + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); }); - }); - it("fetches lazy route modules and allows action to run on fetcher.submit and stores the first resolved value if fetcher is called multiple times", async () => { - let t = setup({ routes: LAZY_ROUTES }); + it("handles errors when failing to load lazy route modules on submission navigation", async () => { + let t = setup({ routes: LAZY_ROUTES }); - let key = "key"; - let A = await t.fetch("/lazy", key, { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - let B = await t.fetch("/lazy", key, { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); - // Resolve B's lazy route first - let actionDfdB = createDeferred(); - let lazyActionStubB = jest.fn(() => actionDfdB.promise); - await B.lazy.lazy.resolve({ - action: lazyActionStubB, + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.loaderData).toEqual({}); }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - // Resolve A's lazy route after B - let actionDfdA = createDeferred(); - let lazyActionStubA = jest.fn(() => actionDfdA.promise); - await A.lazy.lazy.resolve({ - action: lazyActionStubA, + it("handles action errors from lazy route modules on submission navigation", async () => { + let t = setup({ routes: LAZY_ROUTES }); + + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + let actionDfd = createDeferred(); + A.lazy.lazy.resolve({ + action: () => actionDfd.promise, + hasErrorBoundary: true, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + await actionDfd.reject(new Error("LAZY ACTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.errors).toEqual({ + lazy: new Error("LAZY ACTION ERROR"), + }); }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - await actionDfdA.resolve("LAZY ACTION A"); - await actionDfdB.resolve("LAZY ACTION B"); + it("bubbles action errors from lazy route modules when the route specifies hasErrorBoundary:false", async () => { + let t = setup({ routes: LAZY_ROUTES }); - expect(t.router.state.fetchers.get(key)?.state).toBe("idle"); - expect(t.router.state.fetchers.get(key)?.data).toBe("LAZY ACTION B"); - expect(lazyActionStubA).not.toHaveBeenCalled(); - expect(lazyActionStubB).toHaveBeenCalledTimes(2); - }); + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - it("handles action errors in lazy route modules on fetcher.submit", async () => { - let t = setup({ routes: LAZY_ROUTES }); + let actionDfd = createDeferred(); + A.lazy.lazy.resolve({ + action: () => actionDfd.promise, + hasErrorBoundary: false, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); - let key = "key"; - let A = await t.fetch("/lazy", key, { - formMethod: "post", - formData: createFormData({}), + await actionDfd.reject(new Error("LAZY ACTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY ACTION ERROR"), + }); }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - let actionDfd = createDeferred(); - await A.lazy.lazy.resolve({ - action: () => actionDfd.promise, - }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + it("handles errors when failing to load lazy route modules on fetcher.load", async () => { + let t = setup({ routes: LAZY_ROUTES }); - await actionDfd.reject(new Error("LAZY ACTION ERROR")); - await tick(); - expect(t.router.state.fetchers.get(key)).toBeUndefined(); - expect(t.router.state.errors).toEqual({ - root: new Error("LAZY ACTION ERROR"), + let key = "key"; + let A = await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); }); - }); - it("fetches lazy route modules on staticHandler.query()", async () => { - let { query } = createStaticHandler([ - { - id: "lazy", - path: "/lazy", - lazy: async () => { - await tick(); - return { - async loader() { - return json({ value: "LAZY LOADER" }); - }, - }; - }, - }, - ]); + it("handles loader errors in lazy route modules on fetcher.load", async () => { + let t = setup({ routes: LAZY_ROUTES }); - let context = await query(createRequest("/lazy")); - invariant( - !(context instanceof Response), - "Expected a StaticContext instance" - ); - expect(context.loaderData).toEqual({ lazy: { value: "LAZY LOADER" } }); - }); + let key = "key"; + let A = await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - it("throws when failing to load lazy route modules on staticHandler.query()", async () => { - let { query } = createStaticHandler([ - { - id: "root", - path: "/", - children: [ - { - id: "lazy", - path: "/lazy", - lazy: async () => { - throw new Error("LAZY FUNCTION ERROR"); - }, - }, - ], - }, - ]); + let loaderDfd = createDeferred(); + await A.lazy.lazy.resolve({ + loader: () => loaderDfd.promise, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); - let context = await query(createRequest("/lazy")); - invariant( - !(context instanceof Response), - "Expected a StaticContext instance" - ); - expect(context.errors).toEqual({ - root: new Error("LAZY FUNCTION ERROR"), + await loaderDfd.reject(new Error("LAZY LOADER ERROR")); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); }); - }); - it("keeps existing loader when using staticHandler.query() if static loader is already defined", async () => { - let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderStub = jest.fn(async () => { - await tick(); - return json({ value: "LAZY LOADER" }); - }); + it("handles errors when failing to load lazy route modules on fetcher.submit", async () => { + let t = setup({ routes: LAZY_ROUTES }); - let { query } = createStaticHandler([ - { - id: "lazy", - path: "/lazy", - loader: async () => { - await tick(); - return json({ value: "STATIC LOADER" }); - }, - lazy: async () => { - await tick(); - return { - loader: lazyLoaderStub, - }; - }, - }, - ]); + let key = "key"; + let A = await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - let context = await query(createRequest("/lazy")); - invariant( - !(context instanceof Response), - "Expected a StaticContext instance" - ); - expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" } }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + await A.lazy.lazy.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); + }); - expect(consoleWarn).toHaveBeenCalledTimes(1); - expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( - `"Route "lazy" has a static property "loader" defined but its lazy function is also returning a value for this property. The lazy route property "loader" will be ignored."` - ); - consoleWarn.mockReset(); - }); + it("handles action errors in lazy route modules on fetcher.submit", async () => { + let t = setup({ routes: LAZY_ROUTES }); - it("handles loader errors in lazy route modules on staticHandler.query()", async () => { - let { query } = createStaticHandler([ - { - id: "root", - path: "/", - children: [ - { - id: "lazy", - path: "/lazy", - lazy: async () => { - await tick(); - return { - async loader() { - throw new Error("LAZY LOADER ERROR"); - }, - hasErrorBoundary: true, - }; - }, - }, - ], - }, - ]); + let key = "key"; + let A = await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); - let context = await query(createRequest("/lazy")); - invariant( - !(context instanceof Response), - "Expected a StaticContext instance" - ); - expect(context.loaderData).toEqual({ - root: null, - }); - expect(context.errors).toEqual({ - lazy: new Error("LAZY LOADER ERROR"), + let actionDfd = createDeferred(); + await A.lazy.lazy.resolve({ + action: () => actionDfd.promise, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + + await actionDfd.reject(new Error("LAZY ACTION ERROR")); + await tick(); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY ACTION ERROR"), + }); }); - }); - it("handles bubbling of loader errors in lazy route modules on staticHandler.query() when hasErrorBoundary is resolved as false", async () => { - let { query } = createStaticHandler([ - { - id: "root", - path: "/", - children: [ - { - id: "lazy", - path: "/lazy", - lazy: async () => { - await tick(); - return { - async loader() { - throw new Error("LAZY LOADER ERROR"); - }, - hasErrorBoundary: false, - }; + it("throws when failing to load lazy route modules on staticHandler.query()", async () => { + let { query } = createStaticHandler([ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "/lazy", + lazy: async () => { + throw new Error("LAZY FUNCTION ERROR"); + }, }, - }, - ], - }, - ]); + ], + }, + ]); - let context = await query(createRequest("/lazy")); - invariant( - !(context instanceof Response), - "Expected a StaticContext instance" - ); - expect(context.loaderData).toEqual({ - root: null, - }); - expect(context.errors).toEqual({ - root: new Error("LAZY LOADER ERROR"), + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); }); - }); - it("fetches lazy route modules on staticHandler.queryRoute()", async () => { - let { queryRoute } = createStaticHandler([ - { - id: "lazy", - path: "/lazy", - lazy: async () => { - await tick(); - return { - async loader() { - return json({ value: "LAZY LOADER" }); + it("handles loader errors from lazy route modules on staticHandler.query()", async () => { + let { query } = createStaticHandler([ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "/lazy", + lazy: async () => { + await tick(); + return { + async loader() { + throw new Error("LAZY LOADER ERROR"); + }, + hasErrorBoundary: true, + }; + }, }, - }; + ], }, - }, - ]); + ]); - let response = await queryRoute(createRequest("/lazy")); - let data = await response.json(); - expect(data).toEqual({ value: "LAZY LOADER" }); - }); + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ + root: null, + }); + expect(context.errors).toEqual({ + lazy: new Error("LAZY LOADER ERROR"), + }); + }); - it("throws when failing to load lazy route modules on staticHandler.queryRoute()", async () => { - let { queryRoute } = createStaticHandler([ - { - id: "lazy", - path: "/lazy", - lazy: async () => { - throw new Error("LAZY FUNCTION ERROR"); + it("bubbles loader errors from lazy route modules on staticHandler.query() when hasErrorBoundary is resolved as false", async () => { + let { query } = createStaticHandler([ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "/lazy", + lazy: async () => { + await tick(); + return { + async loader() { + throw new Error("LAZY LOADER ERROR"); + }, + hasErrorBoundary: false, + }; + }, + }, + ], }, - }, - ]); - - let err; - try { - await queryRoute(createRequest("/lazy")); - } catch (_err) { - err = _err; - } - - expect(err?.message).toBe("LAZY FUNCTION ERROR"); - }); + ]); - it("keeps existing loader when using staticHandler.queryRoute() if static loader is already defined ", async () => { - let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderStub = jest.fn(async () => { - await tick(); - return json({ value: "LAZY LOADER" }); + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ + root: null, + }); + expect(context.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); }); - let { query } = createStaticHandler([ - { - id: "lazy", - path: "/lazy", - loader: async () => { - await tick(); - return json({ value: "STATIC LOADER" }); - }, - lazy: async () => { - await tick(); - return { - loader: lazyLoaderStub, - }; + it("throws when failing to load lazy route modules on staticHandler.queryRoute()", async () => { + let { queryRoute } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + lazy: async () => { + throw new Error("LAZY FUNCTION ERROR"); + }, }, - }, - ]); + ]); - let context = await query(createRequest("/lazy")); - invariant( - !(context instanceof Response), - "Expected a StaticContext instance" - ); - expect(context.loaderData).toEqual({ lazy: { value: "STATIC LOADER" } }); - expect(lazyLoaderStub).not.toHaveBeenCalled(); + let err; + try { + await queryRoute(createRequest("/lazy")); + } catch (_err) { + err = _err; + } - expect(consoleWarn).toHaveBeenCalledTimes(1); - expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( - `"Route "lazy" has a static property "loader" defined but its lazy function is also returning a value for this property. The lazy route property "loader" will be ignored."` - ); - consoleWarn.mockReset(); - }); + expect(err?.message).toBe("LAZY FUNCTION ERROR"); + }); - it("handles loader errors in lazy route modules on staticHandler.queryRoute()", async () => { - let { queryRoute } = createStaticHandler([ - { - id: "lazy", - path: "/lazy", - lazy: async () => { - await tick(); - return { - async loader() { - throw new Error("LAZY LOADER ERROR"); - }, - }; + it("handles loader errors in lazy route modules on staticHandler.queryRoute()", async () => { + let { queryRoute } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + lazy: async () => { + await tick(); + return { + async loader() { + throw new Error("LAZY LOADER ERROR"); + }, + }; + }, }, - }, - ]); + ]); - let err; - try { - await queryRoute(createRequest("/lazy")); - } catch (_err) { - err = _err; - } + let err; + try { + await queryRoute(createRequest("/lazy")); + } catch (_err) { + err = _err; + } - expect(err?.message).toBe("LAZY LOADER ERROR"); + expect(err?.message).toBe("LAZY LOADER ERROR"); + }); }); }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 83d2047ba4..9988e79be4 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3235,9 +3235,9 @@ async function loadLazyRouteModule( let lazyRoute = await route.lazy(); - // If the lazy route function has already been executed and removed from - // the route object by another call while we were waiting for the promise - // to resolve then we don't want to resolve the same route again. + // If the lazy route function was executed and removed by another parallel + // call then we can return - first lazy() to finish wins because the return + // value of lazy is expected to be static if (!route.lazy) { return; } @@ -3245,15 +3245,14 @@ async function loadLazyRouteModule( let routeToUpdate = manifest[route.id]; invariant(routeToUpdate, "No route found in manifest"); - // For now, we update in place. We think this is ok since there's no way - // we could yet be sitting on this route since we can't get there without - // resolving through here first. This is different than the HMR "update" - // use-case where we may actively be on the route being updated. The main - // concern boils down to "does this mutation affect any ongoing navigations - // or any current state.matches values?". If not, I think it's safe to - // mutate in place. It's also worth noting that this is a more targeted - // update that cannot touch things like path/index/children so it cannot - // affect the routes we've already matched. + // Update the route in place. This should be safe because there's no way + // we could yet be sitting on this route as we can't get there without + // resolving lazy() first. + // + // This is different than the HMR "update" use-case where we may actively be + // on the route being updated. The main concern boils down to "does this + // mutation affect any ongoing navigations or any current state.matches + // values?". If not, it should be safe to update in place. let routeUpdates: Record = {}; for (let lazyRouteProperty in lazyRoute) { let staticRouteValue = From caed6bf0d143a474dfb7a04b91f22c9689cb0297 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Feb 2023 11:51:00 -0500 Subject: [PATCH 45/64] hasErrorBoundary function -> detectErrorBoundary --- packages/react-router-dom/index.tsx | 6 ++-- packages/react-router-dom/server.tsx | 8 ++--- packages/react-router/index.ts | 2 +- packages/router/router.ts | 46 +++++++++++++++------------- packages/router/utils.ts | 10 +++--- 5 files changed, 38 insertions(+), 34 deletions(-) diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index cdb06b280b..261d46ced6 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -209,7 +209,7 @@ export function createBrowserRouter( history: createBrowserHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), routes, - hasErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), + detectErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), }).initialize(); } @@ -226,7 +226,9 @@ export function createHashRouter( history: createHashHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), routes, - hasErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), + // Note: this check also occurs in createRoutesFromChildren so update + // there if you change this + detectErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), }).initialize(); } diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 27d37b2b1a..8478d8cf5b 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -206,11 +206,11 @@ function getStatelessNavigator() { }; } -let hasErrorBoundary = (route: RouteObject) => Boolean(route.errorElement); +let detectErrorBoundary = (route: RouteObject) => Boolean(route.errorElement); type CreateStaticHandlerOptions = Omit< RouterCreateStaticHandlerOptions, - "hasErrorBoundary" + "detectErrorBoundary" >; export function createStaticHandler( @@ -219,7 +219,7 @@ export function createStaticHandler( ) { return routerCreateStaticHandler(routes, { ...opts, - hasErrorBoundary, + detectErrorBoundary, }); } @@ -230,7 +230,7 @@ export function createStaticRouter( let manifest: UNSAFE_RouteManifest = {}; let dataRoutes = convertRoutesToDataRoutes( routes, - hasErrorBoundary, + detectErrorBoundary, undefined, manifest ); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 3f0aef8bfe..886fafa986 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -222,7 +222,7 @@ export function createMemoryRouter( }), hydrationData: opts?.hydrationData, routes, - hasErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), + detectErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), }).initialize(); } diff --git a/packages/router/router.ts b/packages/router/router.ts index 9988e79be4..8812b6ab9d 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -14,7 +14,7 @@ import type { ErrorResult, FormEncType, FormMethod, - HasErrorBoundaryFunction, + DetectErrorBoundaryFunction, RedirectResult, RouteData, AgnosticRouteObject, @@ -343,7 +343,7 @@ export interface RouterInit { routes: AgnosticRouteObject[]; history: History; hydrationData?: HydrationState; - hasErrorBoundary?: HasErrorBoundaryFunction; + detectErrorBoundary?: DetectErrorBoundaryFunction; } /** @@ -655,7 +655,7 @@ const isBrowser = typeof window.document.createElement !== "undefined"; const isServer = !isBrowser; -const defaultHasErrorBoundary = (route: AgnosticRouteObject) => +const defaultDetectErrorBoundary = (route: AgnosticRouteObject) => Boolean(route.hasErrorBoundary); //#endregion @@ -672,14 +672,15 @@ export function createRouter(init: RouterInit): Router { "You must provide a non-empty routes array to createRouter" ); - let hasErrorBoundary = init.hasErrorBoundary || defaultHasErrorBoundary; + let detectErrorBoundary = + init.detectErrorBoundary || defaultDetectErrorBoundary; // Routes keyed by ID let manifest: RouteManifest = {}; // Routes in tree format for matching let dataRoutes = convertRoutesToDataRoutes( init.routes, - hasErrorBoundary, + detectErrorBoundary, undefined, manifest ); @@ -884,7 +885,7 @@ export function createRouter(init: RouterInit): Router { // Load lazy modules, then kick off initial data load if needed let lazyPromises = lazyMatches.map((m) => - loadLazyRouteModule(m.route, hasErrorBoundary, manifest) + loadLazyRouteModule(m.route, detectErrorBoundary, manifest) ); Promise.all(lazyPromises).then(() => { let initialized = @@ -1358,7 +1359,7 @@ export function createRouter(init: RouterInit): Router { actionMatch, matches, manifest, - hasErrorBoundary, + detectErrorBoundary, router.basename ); @@ -1688,7 +1689,7 @@ export function createRouter(init: RouterInit): Router { match, requestMatches, manifest, - hasErrorBoundary, + detectErrorBoundary, router.basename ); @@ -1915,7 +1916,7 @@ export function createRouter(init: RouterInit): Router { match, matches, manifest, - hasErrorBoundary, + detectErrorBoundary, router.basename ); @@ -2115,7 +2116,7 @@ export function createRouter(init: RouterInit): Router { match, matches, manifest, - hasErrorBoundary, + detectErrorBoundary, router.basename ) ), @@ -2127,7 +2128,7 @@ export function createRouter(init: RouterInit): Router { f.match, f.matches, manifest, - hasErrorBoundary, + detectErrorBoundary, router.basename ); } else { @@ -2447,7 +2448,7 @@ export const UNSAFE_DEFERRED_SYMBOL = Symbol("deferred"); export interface CreateStaticHandlerOptions { basename?: string; - hasErrorBoundary?: HasErrorBoundaryFunction; + detectErrorBoundary?: DetectErrorBoundaryFunction; } export function createStaticHandler( @@ -2460,10 +2461,11 @@ export function createStaticHandler( ); let manifest: RouteManifest = {}; - let hasErrorBoundary = opts?.hasErrorBoundary || defaultHasErrorBoundary; + let detectErrorBoundary = + opts?.detectErrorBoundary || defaultDetectErrorBoundary; let dataRoutes = convertRoutesToDataRoutes( routes, - hasErrorBoundary, + detectErrorBoundary, undefined, manifest ); @@ -2721,7 +2723,7 @@ export function createStaticHandler( actionMatch, matches, manifest, - hasErrorBoundary, + detectErrorBoundary, basename, true, isRouteRequest, @@ -2889,7 +2891,7 @@ export function createStaticHandler( match, matches, manifest, - hasErrorBoundary, + detectErrorBoundary, basename, true, isRouteRequest, @@ -3226,7 +3228,7 @@ function shouldRevalidateLoader( */ async function loadLazyRouteModule( route: AgnosticDataRouteObject, - hasErrorBoundary: HasErrorBoundaryFunction, + detectErrorBoundary: DetectErrorBoundaryFunction, manifest: RouteManifest ) { if (!route.lazy) { @@ -3281,7 +3283,7 @@ async function loadLazyRouteModule( } // Mutate the route with the provided updates. Do this first so we pass - // the updated version to hasErrorBoundary + // the updated version to detectErrorBoundary Object.assign(routeToUpdate, routeUpdates); // Mutate the `hasErrorBoundary` property on the route based on the route @@ -3289,9 +3291,9 @@ async function loadLazyRouteModule( // route again. Object.assign(routeToUpdate, { // To keep things framework agnostic, we use the provided - // `hasErrorBoundary` function to set the `hasErrorBoundary` route + // `detectErrorBoundary` function to set the `hasErrorBoundary` route // property since the logic will differ between frameworks. - hasErrorBoundary: hasErrorBoundary({ ...routeToUpdate }), + hasErrorBoundary: detectErrorBoundary({ ...routeToUpdate }), lazy: undefined, }); } @@ -3302,7 +3304,7 @@ async function callLoaderOrAction( match: AgnosticDataRouteMatch, matches: AgnosticDataRouteMatch[], manifest: RouteManifest, - hasErrorBoundary: HasErrorBoundaryFunction, + detectErrorBoundary: DetectErrorBoundaryFunction, basename = "/", isStaticRequest: boolean = false, isRouteRequest: boolean = false, @@ -3316,7 +3318,7 @@ async function callLoaderOrAction( try { // Load any lazy route modules as part of the loader/action phase if (match.route.lazy) { - await loadLazyRouteModule(match.route, hasErrorBoundary, manifest); + await loadLazyRouteModule(match.route, detectErrorBoundary, manifest); } else { invariant( match.route[type], diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 5b6f683d65..90755304c4 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -143,7 +143,7 @@ export interface ShouldRevalidateFunction { * Function provided by the framework-aware layers to set `hasErrorBoundary` * from the framework-aware `errorElement` prop */ -export interface HasErrorBoundaryFunction { +export interface DetectErrorBoundaryFunction { (route: AgnosticRouteObject): boolean; } @@ -318,7 +318,7 @@ function isIndexRoute( // solely with AgnosticDataRouteObject's within the Router export function convertRoutesToDataRoutes( routes: AgnosticRouteObject[], - hasErrorBoundary: HasErrorBoundaryFunction, + detectErrorBoundary: DetectErrorBoundaryFunction, parentPath: number[] = [], manifest: RouteManifest = {} ): AgnosticDataRouteObject[] { @@ -338,7 +338,7 @@ export function convertRoutesToDataRoutes( if (isIndexRoute(route)) { let indexRoute: AgnosticDataIndexRouteObject = { ...route, - hasErrorBoundary: hasErrorBoundary(route), + hasErrorBoundary: detectErrorBoundary(route), id, }; manifest[id] = indexRoute; @@ -347,7 +347,7 @@ export function convertRoutesToDataRoutes( let pathOrLayoutRoute: AgnosticDataNonIndexRouteObject = { ...route, id, - hasErrorBoundary: hasErrorBoundary(route), + hasErrorBoundary: detectErrorBoundary(route), children: undefined, }; manifest[id] = pathOrLayoutRoute; @@ -355,7 +355,7 @@ export function convertRoutesToDataRoutes( if (route.children) { pathOrLayoutRoute.children = convertRoutesToDataRoutes( route.children, - hasErrorBoundary, + detectErrorBoundary, treePath, manifest ); From 5d329ae19e08fe0d1ac14a791e7de6e2bb6145f3 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Feb 2023 16:26:35 -0500 Subject: [PATCH 46/64] Add support for route Component/ErrorBoundary props --- .changeset/lazy-route-modules.md | 11 ++-- docs/route/lazy.md | 12 ++-- examples/data-router/src/app.tsx | 53 +++++++++------ .../src/pages/About.tsx | 4 +- .../__tests__/exports-test.tsx | 14 +++- packages/react-router-dom/index.tsx | 36 ++--------- .../__tests__/exports-test.tsx | 14 +++- .../createRoutesFromChildren-test.tsx | 20 ++++++ .../__tests__/data-memory-router-test.tsx | 16 ++--- packages/react-router/index.ts | 27 +++++++- packages/react-router/lib/components.tsx | 12 +++- packages/react-router/lib/context.ts | 4 ++ packages/react-router/lib/hooks.tsx | 64 ++++++++++++------- packages/router/history.ts | 2 +- packages/router/index.ts | 6 +- packages/router/router.ts | 2 +- packages/router/utils.ts | 22 +------ 17 files changed, 190 insertions(+), 129 deletions(-) diff --git a/.changeset/lazy-route-modules.md b/.changeset/lazy-route-modules.md index ba1240f308..bd7c9c3783 100644 --- a/.changeset/lazy-route-modules.md +++ b/.changeset/lazy-route-modules.md @@ -6,7 +6,7 @@ **Introducing Lazy Route Modules!** -In order to keep your application bundles small and support code-splitting of your routes, we've introduced a new `lazy()` route property. This is an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`, `errorElement`, etc.). +In order to keep your application bundles small and support code-splitting of your routes, we've introduced a new `lazy()` route property. This is an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`, `errorElement`, etc.). Additionally, as we will show below, we've added support for route `Component`and `ErrorBoundary` fields that take precedence over `element`/`errorElement` and make a bit more sense in a statically-defined router as well as when using `route.lazy()`. Lazy routes are resolved on initial load and during the `loading` or `submitting` phase of a navigation or fetcher call. You cannot lazily define route-matching properties (`path`, `index`, `children`) since we only execute your lazy route functions after we've matched known routes. @@ -33,7 +33,8 @@ export async function loader({ request }) { return json(data); } -function Component() { +// Export a `Component` directly instead of needing to create a React element from it +export function Component() { let data = useLoaderData(); return ( @@ -44,9 +45,7 @@ function Component() { ); } -export const element = ; - -function ErrorBoundary() { +export function ErrorBoundary() { let error = useRouteError(); return isRouteErrorResponse(error) ? (

    @@ -56,8 +55,6 @@ function ErrorBoundary() {

    {error.message || error}

    ); } - -export const errorElement = ; ``` An example of this in action can be found in the [`examples/lazy-loading-router-provider`](https://github.com/remix-run/react-router/tree/main/examples/lazy-loading-router-provider) directory of the repository. diff --git a/docs/route/lazy.md b/docs/route/lazy.md index 705c2bef5a..c858834343 100644 --- a/docs/route/lazy.md +++ b/docs/route/lazy.md @@ -7,7 +7,7 @@ new: true In order to keep your application bundles small and support code-splitting of your routes, each route can provide an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`, `errorElement`, etc.). -Lazy routes are resolved on initial load and during the `loading` or `submitting` phase of a navigation or fetcher call. You cannot lazily define route-matching properties (`path`, `index`, `children`) since we only execute your lazy route functions after we've matched known routes. +Lazy routes are resolved on initial load and during the `loading` or `submitting` phase of a navigation or fetcher call. You cannot lazily define route-matching properties (`path`, `index`, `children`, `caseSensitive`) since we only execute your lazy route functions after we've matched known routes. This feature only works if using a data router, see [Picking a Router][pickingarouter] @@ -30,7 +30,7 @@ export async function loader({ request }) { return json(data); } -function Component() { +export function Component() { let data = useLoaderData(); return ( @@ -41,9 +41,10 @@ function Component() { ); } -export const element = ; +// If you want to customize the component display name in react dev tools: +Component.displayName = "SampleLazyRoute"; -function ErrorBoundary() { +export function ErrorBoundary() { let error = useRouteError(); return isRouteErrorResponse(error) ? (

    @@ -54,7 +55,8 @@ function ErrorBoundary() { ); } -export const errorElement = ; +// If you want to customize the component display name in react dev tools: +ErrorBoundary.displayName = "SampleErrorBoundary"; ``` [pickingarouter]: ../routers/picking-a-router diff --git a/examples/data-router/src/app.tsx b/examples/data-router/src/app.tsx index cf871c76a3..2e8ebee30b 100644 --- a/examples/data-router/src/app.tsx +++ b/examples/data-router/src/app.tsx @@ -26,27 +26,38 @@ import { addTodo, deleteTodo, getTodos } from "./todos"; import "./index.css"; -let router = createBrowserRouter( - createRoutesFromElements( - }> - } /> - } - errorElement={} - > - } /> - - } - /> - - ) -); +let router = createBrowserRouter([ + { + path: "/", + Component: Layout, + children: [ + { + index: true, + loader: homeLoader, + Component: Home, + }, + { + path: "todos", + action: todosAction, + loader: todosLoader, + Component: TodosList, + ErrorBoundary: TodosBoundary, + children: [ + { + path: ":id", + loader: todoLoader, + Component: Todo, + }, + ], + }, + { + path: "deferred", + loader: deferredLoader, + Component: DeferredPage, + }, + ], + }, +]); if (import.meta.hot) { import.meta.hot.dispose(() => router.dispose()); diff --git a/examples/lazy-loading-router-provider/src/pages/About.tsx b/examples/lazy-loading-router-provider/src/pages/About.tsx index 44aa231613..9919f718c7 100644 --- a/examples/lazy-loading-router-provider/src/pages/About.tsx +++ b/examples/lazy-loading-router-provider/src/pages/About.tsx @@ -6,7 +6,7 @@ export async function loader() { return "I came from the About.tsx loader function!"; } -function AboutPage() { +export function Component() { let data = useLoaderData() as string; return ( @@ -17,4 +17,4 @@ function AboutPage() { ); } -export const element = ; +Component.displayName = "AboutPage"; diff --git a/packages/react-router-dom/__tests__/exports-test.tsx b/packages/react-router-dom/__tests__/exports-test.tsx index bfffbf208f..91392a2f85 100644 --- a/packages/react-router-dom/__tests__/exports-test.tsx +++ b/packages/react-router-dom/__tests__/exports-test.tsx @@ -1,10 +1,18 @@ import * as ReactRouter from "react-router"; import * as ReactRouterDOM from "react-router-dom"; +let nonReExportedKeys = new Set(["UNSAFE_detectErrorBoundary"]); + describe("react-router-dom", () => { for (let key in ReactRouter) { - it(`re-exports ${key} from react-router`, () => { - expect(ReactRouterDOM[key]).toBe(ReactRouter[key]); - }); + if (!nonReExportedKeys.has(key)) { + it(`re-exports ${key} from react-router`, () => { + expect(ReactRouterDOM[key]).toBe(ReactRouter[key]); + }); + } else { + it(`does not re-export ${key} from react-router`, () => { + expect(ReactRouterDOM[key]).toBe(undefined); + }); + } } }); diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 261d46ced6..d17272f6ac 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -23,6 +23,7 @@ import { UNSAFE_DataRouterStateContext as DataRouterStateContext, UNSAFE_NavigationContext as NavigationContext, UNSAFE_RouteContext as RouteContext, + UNSAFE_detectErrorBoundary as detectErrorBoundary, } from "react-router"; import type { BrowserHistory, @@ -39,9 +40,10 @@ import { createRouter, createBrowserHistory, createHashHistory, - UNSAFE_invariant as invariant, joinPaths, ErrorResponse, + UNSAFE_invariant as invariant, + UNSAFE_warning as warning, } from "@remix-run/router"; import type { @@ -209,7 +211,7 @@ export function createBrowserRouter( history: createBrowserHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), routes, - detectErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), + detectErrorBoundary, }).initialize(); } @@ -226,9 +228,7 @@ export function createHashRouter( history: createHashHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), routes, - // Note: this check also occurs in createRoutesFromChildren so update - // there if you change this - detectErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), + detectErrorBoundary, }).initialize(); } @@ -1227,6 +1227,8 @@ function useScrollRestoration({ } } +export { useScrollRestoration as UNSAFE_useScrollRestoration }; + /** * Setup a callback to be fired on the window's `beforeunload` event. This is * useful for saving some data to `window.localStorage` just before the page @@ -1303,27 +1305,3 @@ function usePrompt({ when, message }: { when: boolean; message: string }) { export { usePrompt as unstable_usePrompt }; //#endregion - -//////////////////////////////////////////////////////////////////////////////// -//#region Utils -//////////////////////////////////////////////////////////////////////////////// - -function warning(cond: boolean, message: string): void { - if (!cond) { - // eslint-disable-next-line no-console - if (typeof console !== "undefined") console.warn(message); - - try { - // Welcome to debugging React Router! - // - // This error is thrown as a convenience so you can more easily - // find the source for a warning that appears in the console by - // enabling "pause on exceptions" in your JavaScript debugger. - throw new Error(message); - // eslint-disable-next-line no-empty - } catch (e) {} - } -} -//#endregion - -export { useScrollRestoration as UNSAFE_useScrollRestoration }; diff --git a/packages/react-router-native/__tests__/exports-test.tsx b/packages/react-router-native/__tests__/exports-test.tsx index 62915c62ef..0c219c4205 100644 --- a/packages/react-router-native/__tests__/exports-test.tsx +++ b/packages/react-router-native/__tests__/exports-test.tsx @@ -1,10 +1,18 @@ import * as ReactRouter from "react-router"; import * as ReactRouterNative from "react-router-native"; +let nonReExportedKeys = new Set(["UNSAFE_detectErrorBoundary"]); + describe("react-router-native", () => { for (let key in ReactRouter) { - it(`re-exports ${key} from react-router`, () => { - expect(ReactRouterNative[key]).toBe(ReactRouter[key]); - }); + if (!nonReExportedKeys.has(key)) { + it(`re-exports ${key} from react-router`, () => { + expect(ReactRouterNative[key]).toBe(ReactRouter[key]); + }); + } else { + it(`does not re-export ${key} from react-router`, () => { + expect(ReactRouterNative[key]).toBe(undefined); + }); + } } }); diff --git a/packages/react-router/__tests__/createRoutesFromChildren-test.tsx b/packages/react-router/__tests__/createRoutesFromChildren-test.tsx index cf89d68f2c..c1ab7fc109 100644 --- a/packages/react-router/__tests__/createRoutesFromChildren-test.tsx +++ b/packages/react-router/__tests__/createRoutesFromChildren-test.tsx @@ -17,10 +17,14 @@ describe("creating routes from JSX", () => { ).toMatchInlineSnapshot(` [ { + "Component": undefined, + "ErrorBoundary": undefined, "action": undefined, "caseSensitive": undefined, "children": [ { + "Component": undefined, + "ErrorBoundary": undefined, "action": undefined, "caseSensitive": undefined, "element":

    @@ -37,6 +41,8 @@ describe("creating routes from JSX", () => { "shouldRevalidate": undefined, }, { + "Component": undefined, + "ErrorBoundary": undefined, "action": undefined, "caseSensitive": undefined, "element":

    @@ -53,10 +59,14 @@ describe("creating routes from JSX", () => { "shouldRevalidate": undefined, }, { + "Component": undefined, + "ErrorBoundary": undefined, "action": undefined, "caseSensitive": undefined, "children": [ { + "Component": undefined, + "ErrorBoundary": undefined, "action": undefined, "caseSensitive": undefined, "element":

    @@ -73,6 +83,8 @@ describe("creating routes from JSX", () => { "shouldRevalidate": undefined, }, { + "Component": undefined, + "ErrorBoundary": undefined, "action": undefined, "caseSensitive": undefined, "element":

    @@ -139,10 +151,14 @@ describe("creating routes from JSX", () => { ).toMatchInlineSnapshot(` [ { + "Component": undefined, + "ErrorBoundary": undefined, "action": undefined, "caseSensitive": undefined, "children": [ { + "Component": undefined, + "ErrorBoundary": undefined, "action": undefined, "caseSensitive": undefined, "element":

    @@ -159,10 +175,14 @@ describe("creating routes from JSX", () => { "shouldRevalidate": [Function], }, { + "Component": undefined, + "ErrorBoundary": undefined, "action": undefined, "caseSensitive": undefined, "children": [ { + "Component": undefined, + "ErrorBoundary": undefined, "action": [Function], "caseSensitive": undefined, "element":

    diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index b641de6df1..18aea4ce76 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -1533,9 +1533,9 @@ describe("", () => { - errorElement + ErrorBoundary - props on  + prop on  @@ -1648,9 +1648,9 @@ describe("", () => { - errorElement + ErrorBoundary - props on  + prop on  @@ -1893,9 +1893,9 @@ describe("", () => { - errorElement + ErrorBoundary - props on  + prop on  @@ -2077,9 +2077,9 @@ describe("", () => { - errorElement + ErrorBoundary - props on  + prop on  diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 886fafa986..18703fa892 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -37,6 +37,7 @@ import { parsePath, redirect, resolvePath, + UNSAFE_warning as warning, } from "@remix-run/router"; import type { @@ -205,6 +206,29 @@ export { useRoutes, }; +function detectErrorBoundary(route: RouteObject) { + if (__DEV__) { + if (route.Component && route.element) { + warning( + false, + "You should not include `Component` and `element` on your route - " + + "`element` will be ignored." + ); + } + if (route.ErrorBoundary && route.errorElement) { + warning( + false, + "You should not include `ErrorBoundary` and `errorElement` on your route - " + + "`errorElement` will be ignored." + ); + } + } + + // Note: this check also occurs in createRoutesFromChildren so update + // there if you change this + return Boolean(route.ErrorBoundary) || Boolean(route.errorElement); +} + export function createMemoryRouter( routes: RouteObject[], opts?: { @@ -222,7 +246,7 @@ export function createMemoryRouter( }), hydrationData: opts?.hydrationData, routes, - detectErrorBoundary: (route: RouteObject) => Boolean(route.errorElement), + detectErrorBoundary, }).initialize(); } @@ -246,4 +270,5 @@ export { RouteContext as UNSAFE_RouteContext, DataRouterContext as UNSAFE_DataRouterContext, DataRouterStateContext as UNSAFE_DataRouterStateContext, + detectErrorBoundary as UNSAFE_detectErrorBoundary, }; diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index be92ae2925..7f0ab8847c 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -16,7 +16,7 @@ import { UNSAFE_invariant as invariant, parsePath, stripBasename, - warning, + UNSAFE_warning as warning, } from "@remix-run/router"; import { useSyncExternalStore as useSyncExternalStoreShim } from "./use-sync-external-store-shim"; @@ -246,6 +246,8 @@ export interface PathRouteProps { children?: React.ReactNode; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; + Component?: React.FunctionComponent | null; + ErrorBoundary?: React.FunctionComponent | null; } export interface LayoutRouteProps extends PathRouteProps {} @@ -264,6 +266,8 @@ export interface IndexRouteProps { children?: undefined; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; + Component?: React.FunctionComponent | null; + ErrorBoundary?: React.FunctionComponent | null; } export type RouteProps = PathRouteProps | LayoutRouteProps | IndexRouteProps; @@ -588,12 +592,16 @@ export function createRoutesFromChildren( id: element.props.id || treePath.join("-"), caseSensitive: element.props.caseSensitive, element: element.props.element, + Component: element.props.Component, index: element.props.index, path: element.props.path, loader: element.props.loader, action: element.props.action, errorElement: element.props.errorElement, - hasErrorBoundary: element.props.errorElement != null, + ErrorBoundary: element.props.ErrorBoundary, + hasErrorBoundary: + element.props.ErrorBoundary != null || + element.props.errorElement != null, shouldRevalidate: element.props.shouldRevalidate, handle: element.props.handle, lazy: element.props.lazy, diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index e7a62047e3..02dffbfcff 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -28,6 +28,8 @@ export interface IndexRouteObject { children?: undefined; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; + Component?: React.FunctionComponent | null; + ErrorBoundary?: React.FunctionComponent | null; lazy?: LazyRouteFunction; } @@ -44,6 +46,8 @@ export interface NonIndexRouteObject { children?: RouteObject[]; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; + Component?: React.FunctionComponent | null; + ErrorBoundary?: React.FunctionComponent | null; lazy?: LazyRouteFunction; } diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 951b434670..b3588cd9c5 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -20,8 +20,8 @@ import { matchRoutes, parsePath, resolveTo, - warning, UNSAFE_getPathContributingMatches as getPathContributingMatches, + UNSAFE_warning as warning, } from "@remix-run/router"; import type { @@ -392,9 +392,11 @@ export function useRoutes( warning( matches == null || - matches[matches.length - 1].route.element !== undefined, - `Matched leaf route at location "${location.pathname}${location.search}${location.hash}" does not have an element. ` + - `This means it will render an with a null value by default resulting in an "empty" page.` + matches[matches.length - 1].route.element !== undefined || + matches[matches.length - 1].route.Component !== undefined, + `Matched leaf route at location "${location.pathname}${location.search}${location.hash}" ` + + `does not have an element or Component. This means it will render an with a ` + + `null value by default resulting in an "empty" page.` ); } @@ -452,7 +454,7 @@ export function useRoutes( return renderedMatches; } -function DefaultErrorElement() { +function DefaultErrorComponent() { let error = useRouteError(); let message = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` @@ -472,7 +474,7 @@ function DefaultErrorElement() {

    You can provide a way better UX than this when your app throws errors by providing your own  - errorElement props on  + ErrorBoundary prop on  <Route>

    @@ -583,7 +585,7 @@ function RenderedRoute({ routeContext, match, children }: RenderedRouteProps) { dataRouterContext && dataRouterContext.static && dataRouterContext.staticContext && - match.route.errorElement + (match.route.errorElement || match.route.ErrorBoundary) ) { dataRouterContext.staticContext._deepestRenderedBoundaryId = match.route.id; } @@ -631,23 +633,39 @@ export function _renderMatches( return renderedMatches.reduceRight((outlet, match, index) => { let error = match.route.id ? errors?.[match.route.id] : null; // Only data routers handle errors - let errorElement = dataRouterState - ? match.route.errorElement || - : null; + let errorElement: React.ReactNode | null = null; + if (dataRouterState) { + if (match.route.ErrorBoundary) { + errorElement = ; + } else if (match.route.errorElement) { + errorElement = match.route.errorElement; + } else { + errorElement = ; + } + } let matches = parentMatches.concat(renderedMatches.slice(0, index + 1)); - let getChildren = () => ( - - {error - ? errorElement - : match.route.element !== undefined - ? match.route.element - : outlet} - - ); + let getChildren = () => { + let children: React.ReactNode = outlet; + if (error) { + children = errorElement; + } else if (match.route.Component) { + children = ; + } else if (match.route.element) { + children = match.route.element; + } + return ( + + ); + }; // Only wrap in an error boundary within data router usages when we have an - // errorElement on this route. Otherwise let it bubble up to an ancestor - // errorElement - return dataRouterState && (match.route.errorElement || index === 0) ? ( + // ErrorBoundary/errorElement on this route. Otherwise let it bubble up to + // an ancestor ErrorBoundary/errorElement + return dataRouterState && + (match.route.ErrorBoundary || match.route.errorElement || index === 0) ? ( data returned from a loader/action/error @@ -947,26 +947,6 @@ export function stripBasename( return pathname.slice(startIndex) || "/"; } -/** - * @private - */ -export function warning(cond: any, message: string): void { - if (!cond) { - // eslint-disable-next-line no-console - if (typeof console !== "undefined") console.warn(message); - - try { - // Welcome to debugging @remix-run/router! - // - // This error is thrown as a convenience so you can more easily - // find the source for a warning that appears in the console by - // enabling "pause on exceptions" in your JavaScript debugger. - throw new Error(message); - // eslint-disable-next-line no-empty - } catch (e) {} - } -} - /** * Returns a resolved path object relative to the given pathname. * From 87f8bf0fb6f58b40d6ec06af8f5e6279008cfd68 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 27 Feb 2023 17:25:29 -0500 Subject: [PATCH 47/64] Remove router.ready() in favor of resolveLazyRoutes() utility --- examples/ssr-data-router/package.json | 6 +- .../ssr-data-router/{server.js => server.mjs} | 34 ++++--- examples/ssr-data-router/src/entry.client.tsx | 21 ++--- examples/ssr-data-router/src/entry.server.tsx | 5 + .../__tests__/data-browser-router-test.tsx | 92 +++++++++++++++++- packages/router/router.ts | 93 ++++++++++--------- 6 files changed, 172 insertions(+), 79 deletions(-) rename examples/ssr-data-router/{server.js => server.mjs} (72%) diff --git a/examples/ssr-data-router/package.json b/examples/ssr-data-router/package.json index 5fb41deca1..46856bab40 100644 --- a/examples/ssr-data-router/package.json +++ b/examples/ssr-data-router/package.json @@ -2,12 +2,12 @@ "name": "ssr-data-router", "private": true, "scripts": { - "dev": "cross-env NODE_ENV=development node server.js", + "dev": "cross-env NODE_ENV=development node server.mjs", "build": "npm run build:client && npm run build:server", "build:client": "vite build --outDir dist/client --ssrManifest", "build:server": "vite build --ssr src/entry.server.tsx --outDir dist/server", - "start": "cross-env NODE_ENV=production node server.js", - "debug": "node --inspect-brk server.js" + "start": "cross-env NODE_ENV=production node server.mjs", + "debug": "node --inspect-brk server.mjs" }, "dependencies": { "@remix-run/node": "^1.12.0", diff --git a/examples/ssr-data-router/server.js b/examples/ssr-data-router/server.mjs similarity index 72% rename from examples/ssr-data-router/server.js rename to examples/ssr-data-router/server.mjs index 393966526b..3c1c309593 100644 --- a/examples/ssr-data-router/server.js +++ b/examples/ssr-data-router/server.mjs @@ -1,7 +1,10 @@ -let path = require("path"); -let fsp = require("fs/promises"); -let express = require("express"); -let { installGlobals } = require("@remix-run/node"); +import * as path from "path"; +import * as fsp from "fs/promises"; +import express from "express"; +import { installGlobals } from "@remix-run/node"; +import compression from "compression"; +import * as url from "url"; +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); // Polyfill Web Fetch API installGlobals(); @@ -21,36 +24,37 @@ async function createServer() { let vite; if (!isProduction) { - vite = await require("vite").createServer({ + let mod = await import("vite"); + vite = await mod.createServer({ root, server: { middlewareMode: "ssr" }, }); app.use(vite.middlewares); } else { - app.use(require("compression")()); + app.use(compression()); app.use(express.static(resolve("dist/client"))); } + let render; + let template; + + if (isProduction) { + template = await fsp.readFile(resolve("dist/client/index.html"), "utf8"); + render = (await import("./dist/server/entry.server.mjs")).render; + } + app.use("*", async (req, res) => { let url = req.originalUrl; try { - let template; - let render; - if (!isProduction) { + console.log("dev!"); template = await fsp.readFile(resolve("index.html"), "utf8"); template = await vite.transformIndexHtml(url, template); render = await vite .ssrLoadModule("src/entry.server.tsx") .then((m) => m.render); - } else { - template = await fsp.readFile( - resolve("dist/client/index.html"), - "utf8" - ); - render = require(resolve("dist/server/entry.server.js")).render; } try { diff --git a/examples/ssr-data-router/src/entry.client.tsx b/examples/ssr-data-router/src/entry.client.tsx index e509391d18..b71a397b51 100644 --- a/examples/ssr-data-router/src/entry.client.tsx +++ b/examples/ssr-data-router/src/entry.client.tsx @@ -1,18 +1,17 @@ import * as React from "react"; import ReactDOM from "react-dom/client"; +import { resolveLazyRoutes } from "@remix-run/router"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { routes } from "./App"; -// If you're using lazy route modules and you haven't yet preloaded them onto -// routes, then you'll need to wait for the router to be initialized before -// hydrating, since it will have initial data to hydrate but it won't yet have -// any router elements to render. -let router = await createBrowserRouter(routes).ready(); +resolveLazyRoutes(routes, window.location).then(() => { + let router = createBrowserRouter(routes); -ReactDOM.hydrateRoot( - document.getElementById("app")!, - - - -); + ReactDOM.hydrateRoot( + document.getElementById("app")!, + + + + ); +}); diff --git a/examples/ssr-data-router/src/entry.server.tsx b/examples/ssr-data-router/src/entry.server.tsx index 48da92f6df..bfa4325b26 100644 --- a/examples/ssr-data-router/src/entry.server.tsx +++ b/examples/ssr-data-router/src/entry.server.tsx @@ -1,6 +1,7 @@ import type * as express from "express"; import * as React from "react"; import ReactDOMServer from "react-dom/server"; +import { resolveLazyRoutes } from "@remix-run/router"; import { createStaticHandler, createStaticRouter, @@ -8,7 +9,11 @@ import { } from "react-router-dom/server"; import { routes } from "./App"; +// Eagerly resolve all lazy routes on the server +let loadedRoutesPromise = resolveLazyRoutes(routes); + export async function render(request: express.Request) { + await loadedRoutesPromise; let { query, dataRoutes } = createStaticHandler(routes); let remixRequest = createFetchRequest(request); let context = await query(remixRequest); diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index 5bf3cabd03..9c85a5b1ea 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -11,6 +11,7 @@ import { import "@testing-library/jest-dom"; import type { Router, RouterInit } from "@remix-run/router"; +import { resolveLazyRoutes } from "@remix-run/router"; import { Form, Link, @@ -4277,7 +4278,7 @@ function testDomRouter( }); it("renders hydration errors on lazy leaf elements", async () => { - let router = await createTestRouter( + let router = createTestRouter( createRoutesFromElements( }> { + let unsubscribe = router.subscribe((updatedState) => { + if (updatedState.initialized) { + unsubscribe(); + resolve(router); + } + }); + }); + + let { container } = render(); + + function Comp() { + let data = useLoaderData(); + let actionData = useActionData(); + let navigation = useNavigation(); + return ( +
    + {data} + {actionData} + {navigation.state} + +
    + ); + } + + function ErrorBoundary() { + let error = useRouteError(); + return

    {error.message}

    ; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
    +
    + parent data + parent action + idle +

    + Kaboom 💥 +

    +
    +
    " + `); + }); + + it("renders hydration errors on lazy leaf elements with preloading", async () => { + let routes = createRoutesFromElements( + }> + ({ + element: , + errorElement: , + })} + /> + + ); + let loadedRoutes = await resolveLazyRoutes(routes, "/child"); + let router = createTestRouter(loadedRoutes, { + window: getWindow("/child"), + hydrationData: { + loaderData: { + "0": "parent data", + }, + actionData: { + "0": "parent action", + }, + errors: { + "0-0": new Error("Kaboom 💥"), + }, + }, + }); let { container } = render(); @@ -4387,7 +4461,7 @@ function testDomRouter( }); it("renders hydration errors on lazy parent elements", async () => { - let router = await createTestRouter( + let router = createTestRouter( createRoutesFromElements( { + let unsubscribe = router.subscribe((updatedState) => { + if (updatedState.initialized) { + unsubscribe(); + resolve(router); + } + }); + }); let { container } = render(); diff --git a/packages/router/router.ts b/packages/router/router.ts index 71ef2f4bcf..f66e96acf9 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -82,18 +82,6 @@ export interface Router { */ initialize(): Router; - /** - * Returns a promise that resolves when the router has been initialized - * including any lazy-loaded route properties. This is useful on the client - * after server-side rendering to ensure that the routes are ready to render - * since all elements and error boundaries have been resolved. - * - * TODO: Rename this and/or initialize()? If we make initialize() async then - * the public router creation functions will become async too which is a - * breaking change. - */ - ready(): Promise; - /** * @internal * PRIVATE - DO NOT USE @@ -903,32 +891,6 @@ export function createRouter(init: RouterInit): Router { return router; } - // Returns a promise that resolves when the router has been initialized - // including any lazy-loaded route properties. This is useful on the client - // after server-side rendering to ensure that the routes are ready to render - // since all elements and error boundaries have been resolved. - // - // Implemented as a Fluent API for ease of: let router = await - // createRouter(init).initialize().ready(); - // - // TODO: Rename this and/or initialize()? If we make initialize() async then - // the public router creation functions will become async too which is a - // breaking change. - function ready(): Promise { - return new Promise((resolve) => { - if (state.initialized) { - resolve(router); - } else { - let unsubscribe = subscribe((updatedState) => { - if (updatedState.initialized) { - unsubscribe(); - resolve(router); - } - }); - } - }); - } - // Clean up a router and it's side effects function dispose() { if (unlistenHistory) { @@ -2414,7 +2376,6 @@ export function createRouter(init: RouterInit): Router { return dataRoutes; }, initialize, - ready, subscribe, enableScrollRestoration, navigate, @@ -3227,9 +3188,9 @@ function shouldRevalidateLoader( * with dataRoutes so those get updated as well. */ async function loadLazyRouteModule( - route: AgnosticDataRouteObject, - detectErrorBoundary: DetectErrorBoundaryFunction, - manifest: RouteManifest + route: AgnosticRouteObject, + detectErrorBoundary?: DetectErrorBoundaryFunction, + manifest?: RouteManifest ) { if (!route.lazy) { return; @@ -3244,8 +3205,11 @@ async function loadLazyRouteModule( return; } - let routeToUpdate = manifest[route.id]; - invariant(routeToUpdate, "No route found in manifest"); + let routeToUpdate = route; + + if (typeof route.id === "string" && manifest && manifest[route.id]) { + routeToUpdate = manifest[route.id] as AgnosticRouteObject; + } // Update the route in place. This should be safe because there's no way // we could yet be sitting on this route as we can't get there without @@ -3290,11 +3254,13 @@ async function loadLazyRouteModule( // updates and remove the `lazy` function so we don't resolve the lazy // route again. Object.assign(routeToUpdate, { + lazy: undefined, // To keep things framework agnostic, we use the provided // `detectErrorBoundary` function to set the `hasErrorBoundary` route // property since the logic will differ between frameworks. - hasErrorBoundary: detectErrorBoundary({ ...routeToUpdate }), - lazy: undefined, + ...(detectErrorBoundary + ? { hasErrorBoundary: detectErrorBoundary({ ...routeToUpdate }) } + : null), }); } @@ -3989,4 +3955,39 @@ function getTargetMatch( let pathMatches = getPathContributingMatches(matches); return pathMatches[pathMatches.length - 1]; } + +// Utility function for users to preemptively run lazy() functions on their +// routes before SSR or hydration +export async function resolveLazyRoutes( + routes: AgnosticRouteObject[], + locationArg?: Partial | string, + basename = "/" +): Promise { + async function recurse( + routes: AgnosticRouteObject[] + ): Promise { + for (let route of routes) { + if (route.children) { + await recurse(route.children); + } + if (route.lazy) { + await loadLazyRouteModule(route); + } + } + return routes; + } + + if (locationArg) { + let matches = matchRoutes(routes, locationArg, basename); + if (matches) { + let matchedRoutes = matches.map((m) => m.route); + await recurse(matchedRoutes); + return routes; + } else { + return routes; + } + } else { + return recurse(routes); + } +} //#endregion From 0886eb4bda5bce5ff50edbc2751186cbe4b4e401 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 27 Feb 2023 17:51:28 -0500 Subject: [PATCH 48/64] Remove server ready() and update decision doc --- decisions/0002-lazy-route-modules.md | 20 +++++++++++++++----- packages/react-router-dom/server.tsx | 3 --- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md index 3055637641..480f3b1e57 100644 --- a/decisions/0002-lazy-route-modules.md +++ b/decisions/0002-lazy-route-modules.md @@ -198,7 +198,7 @@ let router = createBrowserRouter(routes, { hydrationData: window.__hydrationData, }); -// ⚠️ What if we're not initialized here! +// ⚠️ At this point, the router has the data but not the route definition! ReactDOM.hydrateRoot( document.getElementById("app")!, @@ -208,7 +208,7 @@ ReactDOM.hydrateRoot( In the above example, we've server-rendered our `/` route and therefore we _don't_ want to render a `fallbackElement` since we already have the SSR'd content, and the router doesn't need to "initialize" because we've provided the data in `hydrationData`. However, if we're hydrating into a route that includes `lazy`, then we _do_ need to initialize that lazy route. -The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we need to delay the hydration or our `RouterProvider`: +The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we can delay the hydration or our `RouterProvider` by waiting for `state.initialized`: ```jsx if (!router.state.initialized) { @@ -223,12 +223,22 @@ if (!router.state.initialized) { } ``` -At the moment this is implemented in a new `ready()` API that we're still deciding if we'll keep or not: +We've also introduced a `resolveLazyRoutes` utility to help you load lazy route modules ahead of hydration: -```js -let router = await createBrowserRouter(routes).ready(); +```jsx +// Resolve routes in place and create the router/hydrate after +resolveLazyRoutes(routes, window.location).then(() => { + let router = createBrowserRouter(routes); + + ReactDOM.hydrateRoot( + document.getElementById("app")!, + + ); +}); ``` +The second parameter to `resolveLazyRoutes` is optional and will resolve _all_ routes if no location is passed. This can be useful to resolve lazy routes during SSR on server boot-up. + ## Future Optimizations Right now, `lazy()` and `loader()` execution are called sequentially _even if the loader is statically defined_. Eventually we will likely detect the statically-defined `loader` and call it in parallel with `lazy` (since lazy wil be unable to update the loader anyway!). This will provide the ability to obtain the most-optimal parallelization of loading your component in parallel with your loader fetches. diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 8478d8cf5b..2b04f86927 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -276,9 +276,6 @@ export function createStaticRouter( initialize() { throw msg("initialize"); }, - ready() { - throw msg("ready"); - }, subscribe() { throw msg("subscribe"); }, From de8407592090c4fc165ac4c0fc19c2b3cd33ece8 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 1 Mar 2023 11:01:13 -0500 Subject: [PATCH 49/64] Revert "Remove server ready() and update decision doc" This reverts commit 0886eb4bda5bce5ff50edbc2751186cbe4b4e401. --- decisions/0002-lazy-route-modules.md | 20 +++++--------------- packages/react-router-dom/server.tsx | 3 +++ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md index 480f3b1e57..3055637641 100644 --- a/decisions/0002-lazy-route-modules.md +++ b/decisions/0002-lazy-route-modules.md @@ -198,7 +198,7 @@ let router = createBrowserRouter(routes, { hydrationData: window.__hydrationData, }); -// ⚠️ At this point, the router has the data but not the route definition! +// ⚠️ What if we're not initialized here! ReactDOM.hydrateRoot( document.getElementById("app")!, @@ -208,7 +208,7 @@ ReactDOM.hydrateRoot( In the above example, we've server-rendered our `/` route and therefore we _don't_ want to render a `fallbackElement` since we already have the SSR'd content, and the router doesn't need to "initialize" because we've provided the data in `hydrationData`. However, if we're hydrating into a route that includes `lazy`, then we _do_ need to initialize that lazy route. -The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we can delay the hydration or our `RouterProvider` by waiting for `state.initialized`: +The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we need to delay the hydration or our `RouterProvider`: ```jsx if (!router.state.initialized) { @@ -223,22 +223,12 @@ if (!router.state.initialized) { } ``` -We've also introduced a `resolveLazyRoutes` utility to help you load lazy route modules ahead of hydration: +At the moment this is implemented in a new `ready()` API that we're still deciding if we'll keep or not: -```jsx -// Resolve routes in place and create the router/hydrate after -resolveLazyRoutes(routes, window.location).then(() => { - let router = createBrowserRouter(routes); - - ReactDOM.hydrateRoot( - document.getElementById("app")!, - - ); -}); +```js +let router = await createBrowserRouter(routes).ready(); ``` -The second parameter to `resolveLazyRoutes` is optional and will resolve _all_ routes if no location is passed. This can be useful to resolve lazy routes during SSR on server boot-up. - ## Future Optimizations Right now, `lazy()` and `loader()` execution are called sequentially _even if the loader is statically defined_. Eventually we will likely detect the statically-defined `loader` and call it in parallel with `lazy` (since lazy wil be unable to update the loader anyway!). This will provide the ability to obtain the most-optimal parallelization of loading your component in parallel with your loader fetches. diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 2b04f86927..8478d8cf5b 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -276,6 +276,9 @@ export function createStaticRouter( initialize() { throw msg("initialize"); }, + ready() { + throw msg("ready"); + }, subscribe() { throw msg("subscribe"); }, From 3fe87edc638ce13d755c7e93f64082ea1d0d3dfb Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 1 Mar 2023 11:01:29 -0500 Subject: [PATCH 50/64] Revert "Remove router.ready() in favor of resolveLazyRoutes() utility" This reverts commit 87f8bf0fb6f58b40d6ec06af8f5e6279008cfd68. --- examples/ssr-data-router/package.json | 6 +- .../ssr-data-router/{server.mjs => server.js} | 34 +++---- examples/ssr-data-router/src/entry.client.tsx | 21 +++-- examples/ssr-data-router/src/entry.server.tsx | 5 - .../__tests__/data-browser-router-test.tsx | 92 +----------------- packages/router/router.ts | 93 +++++++++---------- 6 files changed, 79 insertions(+), 172 deletions(-) rename examples/ssr-data-router/{server.mjs => server.js} (72%) diff --git a/examples/ssr-data-router/package.json b/examples/ssr-data-router/package.json index 46856bab40..5fb41deca1 100644 --- a/examples/ssr-data-router/package.json +++ b/examples/ssr-data-router/package.json @@ -2,12 +2,12 @@ "name": "ssr-data-router", "private": true, "scripts": { - "dev": "cross-env NODE_ENV=development node server.mjs", + "dev": "cross-env NODE_ENV=development node server.js", "build": "npm run build:client && npm run build:server", "build:client": "vite build --outDir dist/client --ssrManifest", "build:server": "vite build --ssr src/entry.server.tsx --outDir dist/server", - "start": "cross-env NODE_ENV=production node server.mjs", - "debug": "node --inspect-brk server.mjs" + "start": "cross-env NODE_ENV=production node server.js", + "debug": "node --inspect-brk server.js" }, "dependencies": { "@remix-run/node": "^1.12.0", diff --git a/examples/ssr-data-router/server.mjs b/examples/ssr-data-router/server.js similarity index 72% rename from examples/ssr-data-router/server.mjs rename to examples/ssr-data-router/server.js index 3c1c309593..393966526b 100644 --- a/examples/ssr-data-router/server.mjs +++ b/examples/ssr-data-router/server.js @@ -1,10 +1,7 @@ -import * as path from "path"; -import * as fsp from "fs/promises"; -import express from "express"; -import { installGlobals } from "@remix-run/node"; -import compression from "compression"; -import * as url from "url"; -const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +let path = require("path"); +let fsp = require("fs/promises"); +let express = require("express"); +let { installGlobals } = require("@remix-run/node"); // Polyfill Web Fetch API installGlobals(); @@ -24,37 +21,36 @@ async function createServer() { let vite; if (!isProduction) { - let mod = await import("vite"); - vite = await mod.createServer({ + vite = await require("vite").createServer({ root, server: { middlewareMode: "ssr" }, }); app.use(vite.middlewares); } else { - app.use(compression()); + app.use(require("compression")()); app.use(express.static(resolve("dist/client"))); } - let render; - let template; - - if (isProduction) { - template = await fsp.readFile(resolve("dist/client/index.html"), "utf8"); - render = (await import("./dist/server/entry.server.mjs")).render; - } - app.use("*", async (req, res) => { let url = req.originalUrl; try { + let template; + let render; + if (!isProduction) { - console.log("dev!"); template = await fsp.readFile(resolve("index.html"), "utf8"); template = await vite.transformIndexHtml(url, template); render = await vite .ssrLoadModule("src/entry.server.tsx") .then((m) => m.render); + } else { + template = await fsp.readFile( + resolve("dist/client/index.html"), + "utf8" + ); + render = require(resolve("dist/server/entry.server.js")).render; } try { diff --git a/examples/ssr-data-router/src/entry.client.tsx b/examples/ssr-data-router/src/entry.client.tsx index b71a397b51..e509391d18 100644 --- a/examples/ssr-data-router/src/entry.client.tsx +++ b/examples/ssr-data-router/src/entry.client.tsx @@ -1,17 +1,18 @@ import * as React from "react"; import ReactDOM from "react-dom/client"; -import { resolveLazyRoutes } from "@remix-run/router"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { routes } from "./App"; -resolveLazyRoutes(routes, window.location).then(() => { - let router = createBrowserRouter(routes); +// If you're using lazy route modules and you haven't yet preloaded them onto +// routes, then you'll need to wait for the router to be initialized before +// hydrating, since it will have initial data to hydrate but it won't yet have +// any router elements to render. +let router = await createBrowserRouter(routes).ready(); - ReactDOM.hydrateRoot( - document.getElementById("app")!, - - - - ); -}); +ReactDOM.hydrateRoot( + document.getElementById("app")!, + + + +); diff --git a/examples/ssr-data-router/src/entry.server.tsx b/examples/ssr-data-router/src/entry.server.tsx index bfa4325b26..48da92f6df 100644 --- a/examples/ssr-data-router/src/entry.server.tsx +++ b/examples/ssr-data-router/src/entry.server.tsx @@ -1,7 +1,6 @@ import type * as express from "express"; import * as React from "react"; import ReactDOMServer from "react-dom/server"; -import { resolveLazyRoutes } from "@remix-run/router"; import { createStaticHandler, createStaticRouter, @@ -9,11 +8,7 @@ import { } from "react-router-dom/server"; import { routes } from "./App"; -// Eagerly resolve all lazy routes on the server -let loadedRoutesPromise = resolveLazyRoutes(routes); - export async function render(request: express.Request) { - await loadedRoutesPromise; let { query, dataRoutes } = createStaticHandler(routes); let remixRequest = createFetchRequest(request); let context = await query(remixRequest); diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index 9c85a5b1ea..5bf3cabd03 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -11,7 +11,6 @@ import { import "@testing-library/jest-dom"; import type { Router, RouterInit } from "@remix-run/router"; -import { resolveLazyRoutes } from "@remix-run/router"; import { Form, Link, @@ -4278,7 +4277,7 @@ function testDomRouter( }); it("renders hydration errors on lazy leaf elements", async () => { - let router = createTestRouter( + let router = await createTestRouter( createRoutesFromElements( }> { - let unsubscribe = router.subscribe((updatedState) => { - if (updatedState.initialized) { - unsubscribe(); - resolve(router); - } - }); - }); - - let { container } = render(); - - function Comp() { - let data = useLoaderData(); - let actionData = useActionData(); - let navigation = useNavigation(); - return ( -
    - {data} - {actionData} - {navigation.state} - -
    - ); - } - - function ErrorBoundary() { - let error = useRouteError(); - return

    {error.message}

    ; - } - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
    -
    - parent data - parent action - idle -

    - Kaboom 💥 -

    -
    -
    " - `); - }); - - it("renders hydration errors on lazy leaf elements with preloading", async () => { - let routes = createRoutesFromElements( - }> - ({ - element: , - errorElement: , - })} - /> - - ); - let loadedRoutes = await resolveLazyRoutes(routes, "/child"); - let router = createTestRouter(loadedRoutes, { - window: getWindow("/child"), - hydrationData: { - loaderData: { - "0": "parent data", - }, - actionData: { - "0": "parent action", - }, - errors: { - "0-0": new Error("Kaboom 💥"), - }, - }, - }); + ).ready(); let { container } = render(); @@ -4461,7 +4387,7 @@ function testDomRouter( }); it("renders hydration errors on lazy parent elements", async () => { - let router = createTestRouter( + let router = await createTestRouter( createRoutesFromElements( { - let unsubscribe = router.subscribe((updatedState) => { - if (updatedState.initialized) { - unsubscribe(); - resolve(router); - } - }); - }); + ).ready(); let { container } = render(); diff --git a/packages/router/router.ts b/packages/router/router.ts index f66e96acf9..71ef2f4bcf 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -82,6 +82,18 @@ export interface Router { */ initialize(): Router; + /** + * Returns a promise that resolves when the router has been initialized + * including any lazy-loaded route properties. This is useful on the client + * after server-side rendering to ensure that the routes are ready to render + * since all elements and error boundaries have been resolved. + * + * TODO: Rename this and/or initialize()? If we make initialize() async then + * the public router creation functions will become async too which is a + * breaking change. + */ + ready(): Promise; + /** * @internal * PRIVATE - DO NOT USE @@ -891,6 +903,32 @@ export function createRouter(init: RouterInit): Router { return router; } + // Returns a promise that resolves when the router has been initialized + // including any lazy-loaded route properties. This is useful on the client + // after server-side rendering to ensure that the routes are ready to render + // since all elements and error boundaries have been resolved. + // + // Implemented as a Fluent API for ease of: let router = await + // createRouter(init).initialize().ready(); + // + // TODO: Rename this and/or initialize()? If we make initialize() async then + // the public router creation functions will become async too which is a + // breaking change. + function ready(): Promise { + return new Promise((resolve) => { + if (state.initialized) { + resolve(router); + } else { + let unsubscribe = subscribe((updatedState) => { + if (updatedState.initialized) { + unsubscribe(); + resolve(router); + } + }); + } + }); + } + // Clean up a router and it's side effects function dispose() { if (unlistenHistory) { @@ -2376,6 +2414,7 @@ export function createRouter(init: RouterInit): Router { return dataRoutes; }, initialize, + ready, subscribe, enableScrollRestoration, navigate, @@ -3188,9 +3227,9 @@ function shouldRevalidateLoader( * with dataRoutes so those get updated as well. */ async function loadLazyRouteModule( - route: AgnosticRouteObject, - detectErrorBoundary?: DetectErrorBoundaryFunction, - manifest?: RouteManifest + route: AgnosticDataRouteObject, + detectErrorBoundary: DetectErrorBoundaryFunction, + manifest: RouteManifest ) { if (!route.lazy) { return; @@ -3205,11 +3244,8 @@ async function loadLazyRouteModule( return; } - let routeToUpdate = route; - - if (typeof route.id === "string" && manifest && manifest[route.id]) { - routeToUpdate = manifest[route.id] as AgnosticRouteObject; - } + let routeToUpdate = manifest[route.id]; + invariant(routeToUpdate, "No route found in manifest"); // Update the route in place. This should be safe because there's no way // we could yet be sitting on this route as we can't get there without @@ -3254,13 +3290,11 @@ async function loadLazyRouteModule( // updates and remove the `lazy` function so we don't resolve the lazy // route again. Object.assign(routeToUpdate, { - lazy: undefined, // To keep things framework agnostic, we use the provided // `detectErrorBoundary` function to set the `hasErrorBoundary` route // property since the logic will differ between frameworks. - ...(detectErrorBoundary - ? { hasErrorBoundary: detectErrorBoundary({ ...routeToUpdate }) } - : null), + hasErrorBoundary: detectErrorBoundary({ ...routeToUpdate }), + lazy: undefined, }); } @@ -3955,39 +3989,4 @@ function getTargetMatch( let pathMatches = getPathContributingMatches(matches); return pathMatches[pathMatches.length - 1]; } - -// Utility function for users to preemptively run lazy() functions on their -// routes before SSR or hydration -export async function resolveLazyRoutes( - routes: AgnosticRouteObject[], - locationArg?: Partial | string, - basename = "/" -): Promise { - async function recurse( - routes: AgnosticRouteObject[] - ): Promise { - for (let route of routes) { - if (route.children) { - await recurse(route.children); - } - if (route.lazy) { - await loadLazyRouteModule(route); - } - } - return routes; - } - - if (locationArg) { - let matches = matchRoutes(routes, locationArg, basename); - if (matches) { - let matchedRoutes = matches.map((m) => m.route); - await recurse(matchedRoutes); - return routes; - } else { - return routes; - } - } else { - return recurse(routes); - } -} //#endregion From be1963916bf793f090b6503130f00b4c4d3a5114 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 1 Mar 2023 11:24:13 -0500 Subject: [PATCH 51/64] Remove ready() and update tests/docs --- decisions/0002-lazy-route-modules.md | 38 ++++-- examples/ssr-data-router/src/entry.client.tsx | 44 +++++-- .../__tests__/data-browser-router-test.tsx | 108 +++++++++++++++++- packages/react-router-dom/server.tsx | 3 - packages/router/router.ts | 39 ------- 5 files changed, 164 insertions(+), 68 deletions(-) diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md index 3055637641..6db73879f6 100644 --- a/decisions/0002-lazy-route-modules.md +++ b/decisions/0002-lazy-route-modules.md @@ -198,7 +198,7 @@ let router = createBrowserRouter(routes, { hydrationData: window.__hydrationData, }); -// ⚠️ What if we're not initialized here! +// ⚠️ At this point, the router has the data but not the route definition! ReactDOM.hydrateRoot( document.getElementById("app")!, @@ -208,27 +208,45 @@ ReactDOM.hydrateRoot( In the above example, we've server-rendered our `/` route and therefore we _don't_ want to render a `fallbackElement` since we already have the SSR'd content, and the router doesn't need to "initialize" because we've provided the data in `hydrationData`. However, if we're hydrating into a route that includes `lazy`, then we _do_ need to initialize that lazy route. -The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we need to delay the hydration or our `RouterProvider`: +The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we need to delay the hydration or our `RouterProvider`. We can do this in one of two ways: + +**Option 1 - preemptively load lazy initial matches:** + +```jsx +// Determine if any of the initial routes are lazy +let lazyMatches = matchRoutes(routes, window.location)?.filter( + (m) => m.route.lazy +); + +// Load the lazy matches and update the routes before creating your router +// so we can hydrate the SSR-rendered content synchronously +if (lazyMatches && lazyMatches?.length > 0) { + await Promise.all( + lazyMatches.map(async (m) => { + let routeModule = await m.route.lazy!(); + Object.assign(m.route, { ...routeModule, lazy: undefined }); + }) + ); +} + +createRouterAndHydrate(); +``` + +**Option 2 - Wait for router to load lazy initial matches and set `state.initialized=true`:** ```jsx if (!router.state.initialized) { let unsub = router.subscribe((state) => { if (state.initialized) { unsub(); - hydrate(); + createRouterAndHydrate(); } }); } else { - hydrate(); + createRouterAndHydrate(); } ``` -At the moment this is implemented in a new `ready()` API that we're still deciding if we'll keep or not: - -```js -let router = await createBrowserRouter(routes).ready(); -``` - ## Future Optimizations Right now, `lazy()` and `loader()` execution are called sequentially _even if the loader is statically defined_. Eventually we will likely detect the statically-defined `loader` and call it in parallel with `lazy` (since lazy wil be unable to update the loader anyway!). This will provide the ability to obtain the most-optimal parallelization of loading your component in parallel with your loader fetches. diff --git a/examples/ssr-data-router/src/entry.client.tsx b/examples/ssr-data-router/src/entry.client.tsx index e509391d18..384c8adc42 100644 --- a/examples/ssr-data-router/src/entry.client.tsx +++ b/examples/ssr-data-router/src/entry.client.tsx @@ -1,18 +1,38 @@ import * as React from "react"; import ReactDOM from "react-dom/client"; -import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { + createBrowserRouter, + matchRoutes, + RouterProvider, +} from "react-router-dom"; import { routes } from "./App"; -// If you're using lazy route modules and you haven't yet preloaded them onto -// routes, then you'll need to wait for the router to be initialized before -// hydrating, since it will have initial data to hydrate but it won't yet have -// any router elements to render. -let router = await createBrowserRouter(routes).ready(); +hydrate(); -ReactDOM.hydrateRoot( - document.getElementById("app")!, - - - -); +async function hydrate() { + // Determine if any of the initial routes are lazy + let lazyMatches = matchRoutes(routes, window.location)?.filter( + (m) => m.route.lazy + ); + + // Load the lazy matches and update the routes before creating your router + // so we can hydrate the SSR-rendered content synchronously + if (lazyMatches && lazyMatches?.length > 0) { + await Promise.all( + lazyMatches.map(async (m) => { + let routeModule = await m.route.lazy!(); + Object.assign(m.route, { ...routeModule, lazy: undefined }); + }) + ); + } + + let router = createBrowserRouter(routes); + + ReactDOM.hydrateRoot( + document.getElementById("app")!, + + + + ); +} diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index 5bf3cabd03..5ec90eaa6f 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -20,6 +20,7 @@ import { createBrowserRouter, createHashRouter, isRouteErrorResponse, + matchRoutes, useLoaderData, useActionData, useRouteError, @@ -89,6 +90,17 @@ function testDomRouter( return ; } + async function waitForRouterInitialize(router) { + return await new Promise((resolve) => { + let unsubscribe = router.subscribe((updatedState) => { + if (updatedState.initialized) { + unsubscribe(); + resolve(router); + } + }); + }); + } + describe(`Router: ${name}`, () => { let consoleWarn: jest.SpyInstance; let consoleError: jest.SpyInstance; @@ -4277,7 +4289,7 @@ function testDomRouter( }); it("renders hydration errors on lazy leaf elements", async () => { - let router = await createTestRouter( + let router = createTestRouter( createRoutesFromElements( }> ); + + function Comp() { + let data = useLoaderData(); + let actionData = useActionData(); + let navigation = useNavigation(); + return ( +
    + {data} + {actionData} + {navigation.state} + +
    + ); + } + + function ErrorBoundary() { + let error = useRouteError(); + return

    {error.message}

    ; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
    +
    + parent data + parent action + idle +

    + Kaboom 💥 +

    +
    +
    " + `); + }); + + it("renders hydration errors on lazy leaf elements with preloading", async () => { + let routes = createRoutesFromElements( + }> + ({ + element: , + errorElement: , + })} + /> + + ); + + let lazyMatches = matchRoutes(routes, { pathname: "/child" })?.filter( + (m) => m.route.lazy + ); + + if (lazyMatches && lazyMatches?.length > 0) { + await Promise.all( + lazyMatches.map(async (m) => { + let routeModule = await m.route.lazy!(); + Object.assign(m.route, { ...routeModule, lazy: undefined }); + }) + ); + } + + let router = createTestRouter(routes, { + window: getWindow("/child"), + hydrationData: { + loaderData: { + "0": "parent data", + }, + actionData: { + "0": "parent action", + }, + errors: { + "0-0": new Error("Kaboom 💥"), + }, + }, + }); let { container } = render(); @@ -4387,7 +4477,7 @@ function testDomRouter( }); it("renders hydration errors on lazy parent elements", async () => { - let router = await createTestRouter( + let router = createTestRouter( createRoutesFromElements( { + let unsubscribe = router.subscribe((updatedState) => { + if (updatedState.initialized) { + unsubscribe(); + resolve(router); + } + }); + }); let { container } = render(); diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 8478d8cf5b..2b04f86927 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -276,9 +276,6 @@ export function createStaticRouter( initialize() { throw msg("initialize"); }, - ready() { - throw msg("ready"); - }, subscribe() { throw msg("subscribe"); }, diff --git a/packages/router/router.ts b/packages/router/router.ts index 71ef2f4bcf..fcd9d78545 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -82,18 +82,6 @@ export interface Router { */ initialize(): Router; - /** - * Returns a promise that resolves when the router has been initialized - * including any lazy-loaded route properties. This is useful on the client - * after server-side rendering to ensure that the routes are ready to render - * since all elements and error boundaries have been resolved. - * - * TODO: Rename this and/or initialize()? If we make initialize() async then - * the public router creation functions will become async too which is a - * breaking change. - */ - ready(): Promise; - /** * @internal * PRIVATE - DO NOT USE @@ -903,32 +891,6 @@ export function createRouter(init: RouterInit): Router { return router; } - // Returns a promise that resolves when the router has been initialized - // including any lazy-loaded route properties. This is useful on the client - // after server-side rendering to ensure that the routes are ready to render - // since all elements and error boundaries have been resolved. - // - // Implemented as a Fluent API for ease of: let router = await - // createRouter(init).initialize().ready(); - // - // TODO: Rename this and/or initialize()? If we make initialize() async then - // the public router creation functions will become async too which is a - // breaking change. - function ready(): Promise { - return new Promise((resolve) => { - if (state.initialized) { - resolve(router); - } else { - let unsubscribe = subscribe((updatedState) => { - if (updatedState.initialized) { - unsubscribe(); - resolve(router); - } - }); - } - }); - } - // Clean up a router and it's side effects function dispose() { if (unlistenHistory) { @@ -2414,7 +2376,6 @@ export function createRouter(init: RouterInit): Router { return dataRoutes; }, initialize, - ready, subscribe, enableScrollRestoration, navigate, From 1e309fd8f367cff2bdf660b534cbc8596522d371 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 1 Mar 2023 16:07:14 -0500 Subject: [PATCH 52/64] Add more tests for Component/ErrorBoundary --- .../__tests__/data-memory-router-test.tsx | 216 +++++++++++++++++- 1 file changed, 213 insertions(+), 3 deletions(-) diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index 18aea4ce76..1d47ae7400 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -79,7 +79,7 @@ describe("", () => { router = null; }); - it("renders the first route that matches the URL", () => { + it("renders the first route that matches the URL (element)", () => { let { container } = render( Home

    } /> @@ -95,6 +95,22 @@ describe("", () => { `); }); + it("renders the first route that matches the URL (Component)", () => { + let { container } = render( + +

    Home

    } /> +
    + ); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
    +

    + Home +

    +
    " + `); + }); + it("supports a `routes` prop instead of children", () => { let routes = [ { @@ -1045,7 +1061,7 @@ describe("", () => { }); describe("errors", () => { - it("renders hydration errors on leaf elements", async () => { + it("renders hydration errors on leaf elements using errorElement", async () => { let { container } = render( ", () => { `); }); + it("renders hydration errors on leaf elements using ErrorBoundary", async () => { + let { container } = render( + + }> + } + ErrorBoundary={() =>

    {useRouteError()?.message}

    } + /> +
    +
    + ); + + function Comp() { + let data = useLoaderData(); + let actionData = useActionData(); + let navigation = useNavigation(); + return ( +
    + {data} + {actionData} + {navigation.state} + +
    + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
    +
    + parent data + parent action + idle +

    + Kaboom 💥 +

    +
    +
    " + `); + }); + it("renders hydration errors on parent elements", async () => { let { container } = render( ", () => { `); }); - it("renders navigation errors on leaf elements", async () => { + it("renders navigation errors on leaf elements using errorElement", async () => { let fooDefer = createDeferred(); let barDefer = createDeferred(); @@ -1290,6 +1360,146 @@ describe("", () => { `); }); + it("renders navigation errors on leaf elements using ErrorBoundary", async () => { + let fooDefer = createDeferred(); + let barDefer = createDeferred(); + + let { container } = render( + + }> + fooDefer.promise} + element={} + ErrorBoundary={FooError} + /> + barDefer.promise} + element={} + ErrorBoundary={BarError} + /> + + + ); + + function Layout() { + let navigation = useNavigation(); + return ( +
    + Link to Foo + Link to Bar +

    {navigation.state}

    + +
    + ); + } + + function Foo() { + let data = useLoaderData(); + return

    Foo:{data?.message}

    ; + } + function FooError() { + let error = useRouteError(); + return

    Foo Error:{error.message}

    ; + } + function Bar() { + let data = useLoaderData(); + return

    Bar:{data?.message}

    ; + } + function BarError() { + let error = useRouteError(); + return

    Bar Error:{error.message}

    ; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
    +
    + + Link to Foo + + + Link to Bar + +

    + idle +

    +

    + Foo: + hydrated from foo +

    +
    +
    " + `); + + fireEvent.click(screen.getByText("Link to Bar")); + barDefer.reject(new Error("Kaboom!")); + await waitFor(() => screen.getByText("idle")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
    +
    + + Link to Foo + + + Link to Bar + +

    + idle +

    +

    + Bar Error: + Kaboom! +

    +
    +
    " + `); + + fireEvent.click(screen.getByText("Link to Foo")); + fooDefer.reject(new Error("Kaboom!")); + await waitFor(() => screen.getByText("idle")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
    +
    + + Link to Foo + + + Link to Bar + +

    + idle +

    +

    + Foo Error: + Kaboom! +

    +
    +
    " + `); + }); + it("renders navigation errors on parent elements", async () => { let fooDefer = createDeferred(); let barDefer = createDeferred(); From 38d9f06d2654fb029a3f31c73199f814e3c83cea Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 1 Mar 2023 16:07:30 -0500 Subject: [PATCH 53/64] Update docs with notes on Component/ErrorBoundary --- docs/route/error-element.md | 2 ++ docs/route/route.md | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/docs/route/error-element.md b/docs/route/error-element.md index 276586cf2e..0da98ba438 100644 --- a/docs/route/error-element.md +++ b/docs/route/error-element.md @@ -7,6 +7,8 @@ new: true When exceptions are thrown in [loaders][loader], [actions][action], or component rendering, instead of the normal render path for your Routes (``), the error path will be rendered (``) and the error made available with [`useRouteError`][userouteerror]. +If you do not wish to specify a React element (i.e., `errorElement={}`) you may specify an `ErrorBoundary` component instead (i.e., `ErrorBoundary={MyErrorBoundary}`) and React Router will call `createElement` for you internally. + This feature only works if using a data router like [`createBrowserRouter`][createbrowserrouter] ```tsx diff --git a/docs/route/route.md b/docs/route/route.md index 4d6a61f8ca..80c320fb44 100644 --- a/docs/route/route.md +++ b/docs/route/route.md @@ -62,6 +62,8 @@ const router = createBrowserRouter( Neither style is discouraged and behavior is identical. For the majority of this doc we will use the JSX style because that's what most people are accustomed to in the context of React Router. +If you do not wish to specify a React element (i.e., `element={}`) you may specify an `Component` instead (i.e., `Component={MyComponent}`) and React Router will call `createElement` for you internally. + ## Type declaration ```tsx @@ -74,7 +76,9 @@ interface RouteObject { loader?: LoaderFunction; action?: ActionFunction; element?: React.ReactNode | null; + Component?: React.FunctionComponent | null; errorElement?: React.ReactNode | null; + ErrorBoundary?: React.FunctionComponent | null; handle?: RouteObject["handle"]; shouldRevalidate?: ShouldRevalidateFunction; lazy?: LazyRouteFunction; From 4430768115def1079256605c7841670da66ff111 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 1 Mar 2023 16:10:01 -0500 Subject: [PATCH 54/64] Update docs --- decisions/0002-lazy-route-modules.md | 2 +- docs/route/lazy.md | 2 +- docs/route/route.md | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md index 6db73879f6..e883a63fce 100644 --- a/decisions/0002-lazy-route-modules.md +++ b/decisions/0002-lazy-route-modules.md @@ -173,7 +173,7 @@ So in the end, the work for `lazy()` introduced support for `route.Component` an ### Interruptions -Previously when a link was clicked or a form was submitted, since we had the `action`/`loader` defined statically up-front, they were immediately executed and therew was no chance for an interruption _before calling the handler_. Now that we've introduced the concept of `lazy()` there is a period of time prior to executing the handler where the user could interrupt the navigation by clicking to a new location. In order to keep behavior consistent with lazily-loaded routes and statically defined routes, if a `lazy()` function is interrupted React Router _will still call the returned handler_. As always, the user can leverage `request.signal.aborted` inside the handler to short-circuit on interruption if desired. +Previously when a link was clicked or a form was submitted, since we had the `action`/`loader` defined statically up-front, they were immediately executed and there was no chance for an interruption _before calling the handler_. Now that we've introduced the concept of `lazy()` there is a period of time prior to executing the handler where the user could interrupt the navigation by clicking to a new location. In order to keep behavior consistent with lazily-loaded routes and statically defined routes, if a `lazy()` function is interrupted React Router _will still call the returned handler_. As always, the user can leverage `request.signal.aborted` inside the handler to short-circuit on interruption if desired. This is important because `lazy()` is only ever run once in an application session. Once lazy has completed it updates the route in place, and all subsequent navigations to that route use the now-statically-defined properties. Without this behavior, routes would behave differently on the _first_ navigation versus _subsequent_ navigations which could introduce subtle and hard-to-track-down bugs. diff --git a/docs/route/lazy.md b/docs/route/lazy.md index c858834343..a2c37ff45b 100644 --- a/docs/route/lazy.md +++ b/docs/route/lazy.md @@ -5,7 +5,7 @@ new: true # `lazy` -In order to keep your application bundles small and support code-splitting of your routes, each route can provide an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`, `errorElement`, etc.). +In order to keep your application bundles small and support code-splitting of your routes, each route can provide an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `Component`/`element`, `ErrorBoundary`/`errorElement`, etc.). Lazy routes are resolved on initial load and during the `loading` or `submitting` phase of a navigation or fetcher call. You cannot lazily define route-matching properties (`path`, `index`, `children`, `caseSensitive`) since we only execute your lazy route functions after we've matched known routes. diff --git a/docs/route/route.md b/docs/route/route.md index 80c320fb44..5b41bc652d 100644 --- a/docs/route/route.md +++ b/docs/route/route.md @@ -334,7 +334,7 @@ Any application-specific data. Please see the [useMatches][usematches] documenta ## `lazy` -In order to keep your application bundles small and support code-splitting of your routes, each route can provide an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`, `errorElement`, etc.). +In order to keep your application bundles small and support code-splitting of your routes, each route can provide an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `Component`/`element`, `ErrorBoundary`/`errorElement`, etc.). Each `lazy` function will typically return the result of a dynamic import. @@ -355,7 +355,7 @@ export async function loader({ request }) { return json(data); } -function Component() { +export function Component() { let data = useLoaderData(); return ( @@ -365,8 +365,6 @@ function Component() { ); } - -export const element = ; ``` If you are not using a data router like [`createBrowserRouter`][createbrowserrouter], this will do nothing From b6c4d7da9794383036ce463faa9b47ddb4c29c45 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 1 Mar 2023 16:50:36 -0500 Subject: [PATCH 55/64] Minor updates --- .changeset/lazy-route-modules.md | 2 +- decisions/0002-lazy-route-modules.md | 4 ++-- examples/lazy-loading-router-provider/src/App.tsx | 8 +++++--- package.json | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.changeset/lazy-route-modules.md b/.changeset/lazy-route-modules.md index bd7c9c3783..d80b4a5505 100644 --- a/.changeset/lazy-route-modules.md +++ b/.changeset/lazy-route-modules.md @@ -6,7 +6,7 @@ **Introducing Lazy Route Modules!** -In order to keep your application bundles small and support code-splitting of your routes, we've introduced a new `lazy()` route property. This is an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`, `errorElement`, etc.). Additionally, as we will show below, we've added support for route `Component`and `ErrorBoundary` fields that take precedence over `element`/`errorElement` and make a bit more sense in a statically-defined router as well as when using `route.lazy()`. +In order to keep your application bundles small and support code-splitting of your routes, we've introduced a new `lazy()` route property. This is an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`, `errorElement`, etc.). Additionally we've added support for route `Component` and `ErrorBoundary` fields that take precedence over `element`/`errorElement` and make a bit more sense in a statically-defined router as well as when using `route.lazy()`. Lazy routes are resolved on initial load and during the `loading` or `submitting` phase of a navigation or fetcher call. You cannot lazily define route-matching properties (`path`, `index`, `children`) since we only execute your lazy route functions after we've matched known routes. diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md index e883a63fce..46d03ad67e 100644 --- a/decisions/0002-lazy-route-modules.md +++ b/decisions/0002-lazy-route-modules.md @@ -8,11 +8,11 @@ Status: accepted In a data-aware React Router application (``), the router needs to be aware of the route tree ahead of time so it can match routes and execute loaders/actions _prior_ to rendering the destination route. This is different than in non-data-aware React Router applications (``) where you could nest `` sub-tree anywhere in your application, and compose together `` and `React.lazy()` to dynamically load "new" portions of your routing tree as the user navigated through the application. The downside of this approach in `BrowserRouter` is that it's a render-then-fetch cycle which produces network waterfalls and nested spinners, two things that we're aiming to eliminate in `RouterProvider` applications. -There were ways to [manually code-split][manually-code-split] in a `RouterProvider` application but they can be a bit verbose and tedious to do manually. As a result of this DX, we received a [Remix Route Modules Proposal][proposal] from community along with a [POC implementation][poc] (thanks `@rossipedia` 🙌). +There were ways to [manually code-split][manually-code-split] in a `RouterProvider` application but they can be a bit verbose and tedious to do manually. As a result of this DX, we received a [Remix Route Modules Proposal][proposal] from the community along with a [POC implementation][poc] (thanks `@rossipedia` 🙌). ## Original POC -The original POC idea was to implement this in user-land where `element`/`errorElement` would be transformed into `React.Lazy()` calls and `loader`/`action` would load the module and then execute the `loader`/`action`: +The original POC idea was to implement this in user-land where `element`/`errorElement` would be transformed into `React.lazy()` calls and `loader`/`action` would load the module and then execute the `loader`/`action`: ```js // Assuming route.module is a function returning a Remix-style route module diff --git a/examples/lazy-loading-router-provider/src/App.tsx b/examples/lazy-loading-router-provider/src/App.tsx index ee7cac3b36..37da473531 100644 --- a/examples/lazy-loading-router-provider/src/App.tsx +++ b/examples/lazy-loading-router-provider/src/App.tsx @@ -18,20 +18,22 @@ const router = createBrowserRouter([ }, { path: "about", + // Single route in lazy file lazy: () => import("./pages/About"), }, { path: "dashboard", async lazy() { + // Multiple routes in lazy file let { DashboardLayout } = await import("./pages/Dashboard"); - return { element: }; + return { Component: DashboardLayout }; }, children: [ { index: true, async lazy() { let { DashboardIndex } = await import("./pages/Dashboard"); - return { element: }; + return { Component: DashboardIndex }; }, }, { @@ -42,7 +44,7 @@ const router = createBrowserRouter([ ); return { loader: dashboardMessagesLoader, - element: , + Component: DashboardMessages, }; }, }, diff --git a/package.json b/package.json index f0d6febe36..0ddf153277 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "43.1 kB" + "none": "43.0 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "13 kB" From b72d4f2ad33eb522b2afd5f567183518d4ac1d64 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Mar 2023 13:12:41 -0500 Subject: [PATCH 56/64] Optimize execution of static handlers in parallel with lazy --- packages/router/__tests__/router-test.ts | 6 ++ packages/router/router.ts | 94 +++++++++++++----------- 2 files changed, 59 insertions(+), 41 deletions(-) diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 5927304b1a..aeec20eaaa 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -11786,6 +11786,9 @@ describe("a router", () => { let A = await t.navigate("/lazy"); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("loading"); + // Execute in parallel + expect(A.loaders.lazy.stub).toHaveBeenCalled(); + expect(A.lazy.lazy.stub).toHaveBeenCalled(); let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); await A.lazy.lazy.resolve({ @@ -11833,6 +11836,9 @@ describe("a router", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); + // Execute in parallel + expect(A.actions.lazy.stub).toHaveBeenCalled(); + expect(A.lazy.lazy.stub).toHaveBeenCalled(); let lazyActionStub = jest.fn(() => "LAZY ACTION"); let loaderDfd = createDeferred(); diff --git a/packages/router/router.ts b/packages/router/router.ts index 37187b05f8..68f5f715d5 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -26,6 +26,8 @@ import type { ShouldRevalidateFunction, RouteManifest, ImmutableRouteKey, + ActionFunction, + LoaderFunction, } from "./utils"; import { DeferredData, @@ -3276,57 +3278,67 @@ async function callLoaderOrAction( ): Promise { let resultType; let result; - let onReject: (() => void) | undefined; + let runHandler = (handler: ActionFunction | LoaderFunction) => { + // Setup a promise we can race against so that abort signals short circuit + let reject: () => void; + let abortPromise = new Promise((_, r) => (reject = r)); + onReject = () => reject(); + request.signal.addEventListener("abort", onReject); + return Promise.race([ + handler({ request, params: match.params, context: requestContext }), + abortPromise, + ]); + }; + try { - // Load any lazy route modules as part of the loader/action phase + let handler = match.route[type]; + if (match.route.lazy) { - await loadLazyRouteModule(match.route, detectErrorBoundary, manifest); + if (handler) { + // Run statically defined handler in parallel with lazy() + let values = await Promise.all([ + runHandler(handler), + loadLazyRouteModule(match.route, detectErrorBoundary, manifest), + ]); + result = values[0]; + } else { + // Load lazy route module, then run any returned handler + await loadLazyRouteModule(match.route, detectErrorBoundary, manifest); + + handler = match.route[type]; + if (handler) { + // Handler still run even if we got interrupted to maintain consistency + // with un-abortable behavior of handler execution on non-lazy or + // previously-lazy-loaded routes + result = await runHandler(handler); + } else if (type === "action") { + throw getInternalRouterError(405, { + method: request.method, + pathname: new URL(request.url).pathname, + routeId: match.route.id, + }); + } else { + // lazy() route has no loader to run + result = undefined; + } + } } else { invariant( - match.route[type], + handler, `Could not find the ${type} to run on the "${match.route.id}" route` ); - } - - let handler = match.route[type]; - if (!handler) { - if (type === "action") { - throw getInternalRouterError(405, { - method: request.method, - pathname: new URL(request.url).pathname, - routeId: match.route.id, - }); - } else { - // lazy() route has no loader to run - result = undefined; - } - } else { - // Setup a promise we can race against so that abort signals short circuit - let reject: () => void; - let abortPromise = new Promise((_, r) => (reject = r)); - onReject = () => reject(); - request.signal.addEventListener("abort", onReject); - - // Still kick off handlers if we got interrupted to maintain consistency - // with un-abortable behavior of handler execution on non-lazy routes - result = await Promise.race([ - handler({ - request, - params: match.params, - context: requestContext, - }), - abortPromise, - ]); - invariant( - result !== undefined, - `You defined ${type === "action" ? "an action" : "a loader"} ` + - `for route "${match.route.id}" but didn't return anything from your ` + - `\`${type}\` function. Please return a value or \`null\`.` - ); + result = await runHandler(handler); } + + invariant( + result !== undefined, + `You defined ${type === "action" ? "an action" : "a loader"} for route ` + + `"${match.route.id}" but didn't return anything from your \`${type}\` ` + + `function. Please return a value or \`null\`.` + ); } catch (e) { resultType = ResultType.error; result = e; From 629e4235b5e3560cfe273c80e8b6802f4cd53896 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Mar 2023 13:59:53 -0500 Subject: [PATCH 57/64] Fix static router test case --- packages/router/router.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/router/router.ts b/packages/router/router.ts index 68f5f715d5..3e49d4b4a7 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3320,8 +3320,9 @@ async function callLoaderOrAction( routeId: match.route.id, }); } else { - // lazy() route has no loader to run - result = undefined; + // lazy() route has no loader to run. Short circuit here so we don't + // hit the invariant below that errors on returning undefined. + return { type: ResultType.data, data: undefined }; } } } else { From a4b12e9354a8372b63253f1232576765cd6ba655 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Mar 2023 14:28:47 -0500 Subject: [PATCH 58/64] Bundle bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0ddf153277..f0d6febe36 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "43.0 kB" + "none": "43.1 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "13 kB" From 6f8d08f5de65cc3b2fff6e6a76ea150a07d24e63 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 3 Mar 2023 10:35:04 -0500 Subject: [PATCH 59/64] Fix typos Co-authored-by: Mark Dalgleish --- decisions/0002-lazy-route-modules.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md index 46d03ad67e..26f2d87756 100644 --- a/decisions/0002-lazy-route-modules.md +++ b/decisions/0002-lazy-route-modules.md @@ -181,13 +181,13 @@ Additionally, since `lazy()` functions are intended to return a static definitio ### Error Handling -If an error is thrown by `lazy()` we catch that in the same logic as iof the error was thrown by the `action`/`loader` and bubble it to the nearest `errorElement`. +If an error is thrown by `lazy()` we catch that in the same logic as if the error was thrown by the `action`/`loader` and bubble it to the nearest `errorElement`. ## Consequences -Not so much as a consequence, but more of limitation - we still require the routing tree up front-for the most efficient data-loading. This means that we can't _yet_ support quite the same nested `` use-cases as before (particularly with respect to microfrontends), but we have ideas for how to solve tht as an extension of this concept in the future. +Not so much as a consequence, but more of limitation - we still require the routing tree up-front for the most efficient data-loading. This means that we can't _yet_ support quite the same nested `` use-cases as before (particularly with respect to microfrontends), but we have ideas for how to solve that as an extension of this concept in the future. -Another slightly edge-case concept we discovered is that in DIY SSR applications using `createStaticHandler` and `StaticRouterProvider`, it's possible to server-render a lazy route and send up it's hydration data. But then we may _not_ have those routes loaded in our client-side hydration: +Another slightly edge-case concept we discovered is that in DIY SSR applications using `createStaticHandler` and `StaticRouterProvider`, it's possible to server-render a lazy route and send up its hydration data. But then we may _not_ have those routes loaded in our client-side hydration: ```jsx const routes = [{ From d8fe279df7c0deff541fcce4a56609ffc86b3ef4 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 3 Mar 2023 10:36:33 -0500 Subject: [PATCH 60/64] Update to docs --- decisions/0002-lazy-route-modules.md | 50 ++++++++++++---------------- docs/route/lazy.md | 4 +-- 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md index 26f2d87756..3e162b3dc3 100644 --- a/decisions/0002-lazy-route-modules.md +++ b/decisions/0002-lazy-route-modules.md @@ -88,16 +88,24 @@ function Component() { ... } Here's a few choices we made along the way: -### Static Route Properties +### Immutable Route Properties A route has 3 types of fields defined on it: -- Path matching fields: `path`, `index`, `caseSensitive` and `children` +- Path matching properties: `path`, `index`, `caseSensitive` and `children` - While not strictly used for matching, `id` is also considered static since it is needed up-front to uniquely identify all defined routes -- Data loading fields: `loader`, `action`, `hasErrorBoundary`, `shouldRevalidate` -- Rendering fields: `handle` and the framework-aware `element`/`errorElement` +- Data loading properties: `loader`, `action`, `hasErrorBoundary`, `shouldRevalidate` +- Rendering properties: `handle` and the framework-aware `element`/`errorElement` -The `route.lazy()` method is focused on lazy-loading the data loading and rendering fields, but cannot update the path matching fields because we have to path match _first_ before we can even identify which matched routes include a `lazy()` function. Therefore, we do not allow path matching route keys to be updated by `lazy()`, and will log a warning if you return one of those fields from your lazy() method. +The `route.lazy()` method is focused on lazy-loading the data loading and rendering properties, but cannot update the path matching properties because we have to path match _first_ before we can even identify which matched routes include a `lazy()` function. Therefore, we do not allow path matching route keys to be updated by `lazy()`, and will log a warning if you return one of those properties from your lazy() method. + +## Static Route Properties + +Similar to how you cannot override any immutable path-matching properties, you also cannot override any statically defined data-loading or rendering properties (and will log the a console warning if you attempt to). This allows you to statically define aspects that you don't need (or wish) to lazy load. Two potential use-cases her might be: + +1. Using a small statically-defined `loader`/`action` which just hits an API endpoint to load/submit data. + - In fact this is an interesting option we've optimized React Router to detect this and call any statically defined loader/action handlers in parallel with `lazy` (since `lazy` will be unable to update the `loader`/`action` anyway!). This will provide the ability to obtain the most-optimal parallelization of loading your component in parallel with your data fetches. +2. Re-using a common statically-defined `ErrorBoundary` across multiple routes ### Addition of route `Component` and `ErrorBoundary` fields @@ -208,9 +216,9 @@ ReactDOM.hydrateRoot( In the above example, we've server-rendered our `/` route and therefore we _don't_ want to render a `fallbackElement` since we already have the SSR'd content, and the router doesn't need to "initialize" because we've provided the data in `hydrationData`. However, if we're hydrating into a route that includes `lazy`, then we _do_ need to initialize that lazy route. -The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we need to delay the hydration or our `RouterProvider`. We can do this in one of two ways: +The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we need to delay the hydration or our `RouterProvider`. -**Option 1 - preemptively load lazy initial matches:** +The recommended way to do this is to manually match routes against the initial location and load/update any lazy routes before creating your router: ```jsx // Determine if any of the initial routes are lazy @@ -220,7 +228,7 @@ let lazyMatches = matchRoutes(routes, window.location)?.filter( // Load the lazy matches and update the routes before creating your router // so we can hydrate the SSR-rendered content synchronously -if (lazyMatches && lazyMatches?.length > 0) { +if (lazyMatches && lazyMatches.length > 0) { await Promise.all( lazyMatches.map(async (m) => { let routeModule = await m.route.lazy!(); @@ -229,28 +237,14 @@ if (lazyMatches && lazyMatches?.length > 0) { ); } -createRouterAndHydrate(); -``` - -**Option 2 - Wait for router to load lazy initial matches and set `state.initialized=true`:** - -```jsx -if (!router.state.initialized) { - let unsub = router.subscribe((state) => { - if (state.initialized) { - unsub(); - createRouterAndHydrate(); - } - }); -} else { - createRouterAndHydrate(); -} +// Create router and hydrate +let router = createBrowserRouter(routes) +ReactDOM.hydrateRoot( + document.getElementById("app")!, + +); ``` -## Future Optimizations - -Right now, `lazy()` and `loader()` execution are called sequentially _even if the loader is statically defined_. Eventually we will likely detect the statically-defined `loader` and call it in parallel with `lazy` (since lazy wil be unable to update the loader anyway!). This will provide the ability to obtain the most-optimal parallelization of loading your component in parallel with your loader fetches. - [manually-code-split]: https://www.infoxicator.com/en/react-router-6-4-code-splitting [proposal]: https://github.com/remix-run/react-router/discussions/9826 [poc]: https://github.com/remix-run/react-router/pull/9830 diff --git a/docs/route/lazy.md b/docs/route/lazy.md index a2c37ff45b..1453d6b17e 100644 --- a/docs/route/lazy.md +++ b/docs/route/lazy.md @@ -41,7 +41,7 @@ export function Component() { ); } -// If you want to customize the component display name in react dev tools: +// If you want to customize the component display name in React dev tools: Component.displayName = "SampleLazyRoute"; export function ErrorBoundary() { @@ -55,7 +55,7 @@ export function ErrorBoundary() { ); } -// If you want to customize the component display name in react dev tools: +// If you want to customize the component display name in React dev tools: ErrorBoundary.displayName = "SampleErrorBoundary"; ``` From 7b5dc38361e998ad7df432dc10f293134f50d233 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 3 Mar 2023 10:38:37 -0500 Subject: [PATCH 61/64] Few more typos --- docs/route/route.md | 2 +- packages/react-router/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/route/route.md b/docs/route/route.md index 5b41bc652d..4414376e20 100644 --- a/docs/route/route.md +++ b/docs/route/route.md @@ -62,7 +62,7 @@ const router = createBrowserRouter( Neither style is discouraged and behavior is identical. For the majority of this doc we will use the JSX style because that's what most people are accustomed to in the context of React Router. -If you do not wish to specify a React element (i.e., `element={}`) you may specify an `Component` instead (i.e., `Component={MyComponent}`) and React Router will call `createElement` for you internally. +If you do not wish to specify a React element (i.e., `element={}`) you may specify a `Component` instead (i.e., `Component={MyComponent}`) and React Router will call `createElement` for you internally. ## Type declaration diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 18703fa892..a0d2ca597a 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -211,14 +211,14 @@ function detectErrorBoundary(route: RouteObject) { if (route.Component && route.element) { warning( false, - "You should not include `Component` and `element` on your route - " + + "You should not include both `Component` and `element` on your route - " + "`element` will be ignored." ); } if (route.ErrorBoundary && route.errorElement) { warning( false, - "You should not include `ErrorBoundary` and `errorElement` on your route - " + + "You should not include both `ErrorBoundary` and `errorElement` on your route - " + "`errorElement` will be ignored." ); } From 2c9f13d810b430985bb702958e922e4cb295f702 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 6 Mar 2023 09:51:26 -0500 Subject: [PATCH 62/64] Change typing from FunctionComponent -> ComponentType --- docs/route/route.md | 4 ++-- packages/react-router/lib/components.tsx | 8 ++++---- packages/react-router/lib/context.ts | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/route/route.md b/docs/route/route.md index 4414376e20..dacf411466 100644 --- a/docs/route/route.md +++ b/docs/route/route.md @@ -76,9 +76,9 @@ interface RouteObject { loader?: LoaderFunction; action?: ActionFunction; element?: React.ReactNode | null; - Component?: React.FunctionComponent | null; + Component?: React.ComponentType | null; errorElement?: React.ReactNode | null; - ErrorBoundary?: React.FunctionComponent | null; + ErrorBoundary?: React.ComponentType | null; handle?: RouteObject["handle"]; shouldRevalidate?: ShouldRevalidateFunction; lazy?: LazyRouteFunction; diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 7f0ab8847c..ca34e2494a 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -246,8 +246,8 @@ export interface PathRouteProps { children?: React.ReactNode; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; - Component?: React.FunctionComponent | null; - ErrorBoundary?: React.FunctionComponent | null; + Component?: React.ComponentType | null; + ErrorBoundary?: React.ComponentType | null; } export interface LayoutRouteProps extends PathRouteProps {} @@ -266,8 +266,8 @@ export interface IndexRouteProps { children?: undefined; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; - Component?: React.FunctionComponent | null; - ErrorBoundary?: React.FunctionComponent | null; + Component?: React.ComponentType | null; + ErrorBoundary?: React.ComponentType | null; } export type RouteProps = PathRouteProps | LayoutRouteProps | IndexRouteProps; diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index 02dffbfcff..da1340c7aa 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -28,8 +28,8 @@ export interface IndexRouteObject { children?: undefined; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; - Component?: React.FunctionComponent | null; - ErrorBoundary?: React.FunctionComponent | null; + Component?: React.ComponentType | null; + ErrorBoundary?: React.ComponentType | null; lazy?: LazyRouteFunction; } @@ -46,8 +46,8 @@ export interface NonIndexRouteObject { children?: RouteObject[]; element?: React.ReactNode | null; errorElement?: React.ReactNode | null; - Component?: React.FunctionComponent | null; - ErrorBoundary?: React.FunctionComponent | null; + Component?: React.ComponentType | null; + ErrorBoundary?: React.ComponentType | null; lazy?: LazyRouteFunction; } From 6d79d3ddb69eba8a003b75768232f88af6bd5a7c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 7 Mar 2023 09:13:46 -0500 Subject: [PATCH 63/64] Bump bundle --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f0d6febe36..9523a77952 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "none": "13 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "15 kB" + "none": "15.1 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { "none": "11.6 kB" From 2560422a39f5f49e5200e23546815c6dc3a76af7 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 8 Mar 2023 11:10:07 -0500 Subject: [PATCH 64/64] Final docs upates --- .changeset/component-and-error-boundary.md | 42 ++++++++++++++++++++++ .changeset/lazy-route-modules.md | 5 +-- decisions/0002-lazy-route-modules.md | 14 ++++---- packages/react-router-dom/server.tsx | 4 +-- 4 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 .changeset/component-and-error-boundary.md diff --git a/.changeset/component-and-error-boundary.md b/.changeset/component-and-error-boundary.md new file mode 100644 index 0000000000..d6a94f683e --- /dev/null +++ b/.changeset/component-and-error-boundary.md @@ -0,0 +1,42 @@ +--- +"react-router": minor +"react-router-dom": minor +--- + +React Router now supports an alternative way to define your route `element` and `errorElement` fields as React Components instead of React Elements. You can instead pass a React Component to the new `Component` and `ErrorBoundary` fields if you choose. There is no functional difference between the two, so use whichever approach you prefer 😀. You shouldn't be defining both, but if you do `Component`/`ErrorBoundary` will "win". + +**Example JSON Syntax** + +```jsx +// Both of these work the same: +const elementRoutes = [{ + path: '/', + element: , + errorElement: , +}] + +const componentRoutes = [{ + path: '/', + Component: Home, + ErrorBoundary: HomeError, +}] + +function Home() { ... } +function HomeError() { ... } +``` + +**Example JSX Syntax** + +```jsx +// Both of these work the same: +const elementRoutes = createRoutesFromElements( + } errorElement={ } /> +); + +const elementRoutes = createRoutesFromElements( + +); + +function Home() { ... } +function HomeError() { ... } +``` diff --git a/.changeset/lazy-route-modules.md b/.changeset/lazy-route-modules.md index d80b4a5505..67deeafaa8 100644 --- a/.changeset/lazy-route-modules.md +++ b/.changeset/lazy-route-modules.md @@ -6,7 +6,7 @@ **Introducing Lazy Route Modules!** -In order to keep your application bundles small and support code-splitting of your routes, we've introduced a new `lazy()` route property. This is an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`, `errorElement`, etc.). Additionally we've added support for route `Component` and `ErrorBoundary` fields that take precedence over `element`/`errorElement` and make a bit more sense in a statically-defined router as well as when using `route.lazy()`. +In order to keep your application bundles small and support code-splitting of your routes, we've introduced a new `lazy()` route property. This is an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`/`Component`, `errorElement`/`ErrorBoundary`, `shouldRevalidate`, `handle`). Lazy routes are resolved on initial load and during the `loading` or `submitting` phase of a navigation or fetcher call. You cannot lazily define route-matching properties (`path`, `index`, `children`) since we only execute your lazy route functions after we've matched known routes. @@ -33,7 +33,7 @@ export async function loader({ request }) { return json(data); } -// Export a `Component` directly instead of needing to create a React element from it +// Export a `Component` directly instead of needing to create a React Element from it export function Component() { let data = useLoaderData(); @@ -45,6 +45,7 @@ export function Component() { ); } +// Export an `ErrorBoundary` directly instead of needing to create a React Element from it export function ErrorBoundary() { let error = useRouteError(); return isRouteErrorResponse(error) ? ( diff --git a/decisions/0002-lazy-route-modules.md b/decisions/0002-lazy-route-modules.md index 3e162b3dc3..2978903c82 100644 --- a/decisions/0002-lazy-route-modules.md +++ b/decisions/0002-lazy-route-modules.md @@ -51,18 +51,18 @@ Given what we learned from the original POC, we felt we could do this a bit lean This proved to work out quite well as we did our own POC so we went with this approach in the end. Now, any time we enter a `submitting`/`loading` state we first check for a `route.lazy` definition and resolve that promise first and update the internal route definition with the result. -The resulting API looks like this, assuming you want to load your homepage in the main bundle, but lazily load the code for the `/about` route: +The resulting API looks like this, assuming you want to load your homepage in the main bundle, but lazily load the code for the `/about` route. Note we're using the new `Component` API introduced along with this work. ```jsx // app.jsx const router = createBrowserRouter([ { path: "/", - element: , + Component: Layout, children: [ { index: true, - element: , + Component: Home, }, { path: "about", @@ -79,9 +79,7 @@ And then your `about.jsx` file would export the properties to be lazily defined // about.jsx export function loader() { ... } -export const element = - -function Component() { ... } +export function Component() { ... } ``` ## Choices @@ -95,7 +93,7 @@ A route has 3 types of fields defined on it: - Path matching properties: `path`, `index`, `caseSensitive` and `children` - While not strictly used for matching, `id` is also considered static since it is needed up-front to uniquely identify all defined routes - Data loading properties: `loader`, `action`, `hasErrorBoundary`, `shouldRevalidate` -- Rendering properties: `handle` and the framework-aware `element`/`errorElement` +- Rendering properties: `handle` and the framework-aware `element`/`errorElement`/`Component`/`ErrorBoundary` The `route.lazy()` method is focused on lazy-loading the data loading and rendering properties, but cannot update the path matching properties because we have to path match _first_ before we can even identify which matched routes include a `lazy()` function. Therefore, we do not allow path matching route keys to be updated by `lazy()`, and will log a warning if you return one of those properties from your lazy() method. @@ -177,7 +175,7 @@ const routes = [ ]; ``` -So in the end, the work for `lazy()` introduced support for `route.Component` and `route.ErrorBoundary`, which can be statically or lazily defined. `element`/`errorElement` will be considered deprecated in data routers and may go away in version 7. +So in the end, the work for `lazy()` introduced support for `route.Component` and `route.ErrorBoundary`, which can be statically or lazily defined. They will take precedence over `element`/`errorElement` if both happen to be defined, but for now both are acceptable ways to define routes. We think we'll be expanding the `Component` API in the future for stronger type-safety since we can pass it inferred-type `loaderData` etc. so in the future that _may_ become the preferred API. ### Interruptions diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 2b04f86927..ea2d5f0497 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -5,7 +5,7 @@ import type { Router as RemixRouter, StaticHandlerContext, CreateStaticHandlerOptions as RouterCreateStaticHandlerOptions, - UNSAFE_RouteManifest, + UNSAFE_RouteManifest as RouteManifest, } from "@remix-run/router"; import { IDLE_BLOCKER, @@ -227,7 +227,7 @@ export function createStaticRouter( routes: RouteObject[], context: StaticHandlerContext ): RemixRouter { - let manifest: UNSAFE_RouteManifest = {}; + let manifest: RouteManifest = {}; let dataRoutes = convertRoutesToDataRoutes( routes, detectErrorBoundary,