From 4e85e9884c2c0c7125edc91941b3e023a9d3180c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 14 Jun 2024 10:55:37 -0400 Subject: [PATCH] Support lazy route discovery (fog of war) (#11626) --- .changeset/fog-of-war.md | 10 + docs/routers/create-browser-router.md | 168 +- package.json | 10 +- packages/react-router-dom/index.tsx | 5 + packages/react-router-dom/server.tsx | 3 + .../__tests__/data-memory-router-test.tsx | 1 - packages/react-router/index.ts | 6 + .../router/__tests__/lazy-discovery-test.ts | 1607 +++++++++++++++++ packages/router/__tests__/lazy-test.ts | 1 - packages/router/index.ts | 1 + packages/router/router.ts | 659 ++++++- packages/router/utils.ts | 59 +- 12 files changed, 2436 insertions(+), 94 deletions(-) create mode 100644 .changeset/fog-of-war.md create mode 100644 packages/router/__tests__/lazy-discovery-test.ts diff --git a/.changeset/fog-of-war.md b/.changeset/fog-of-war.md new file mode 100644 index 0000000000..1eea19ad1d --- /dev/null +++ b/.changeset/fog-of-war.md @@ -0,0 +1,10 @@ +--- +"react-router-dom": minor +"react-router": minor +"@remix-run/router": minor +--- + +Add support for Lazy Route Discovery (a.k.a. Fog of War) + +- RFC: https://github.com/remix-run/react-router/discussions/11113 +- `unstable_patchRoutesOnMiss` docs: https://reactrouter.com/en/main/routers/create-browser-router diff --git a/docs/routers/create-browser-router.md b/docs/routers/create-browser-router.md index 07bbe697db..b5ebeb34a5 100644 --- a/docs/routers/create-browser-router.md +++ b/docs/routers/create-browser-router.md @@ -51,6 +51,8 @@ function createBrowserRouter( basename?: string; future?: FutureConfig; hydrationData?: HydrationState; + unstable_dataStrategy?: unstable_DataStrategyFunction; + unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction; window?: Window; } ): RemixRouter; @@ -77,7 +79,7 @@ createBrowserRouter([ ]); ``` -## `basename` +## `opts.basename` The basename of the app for situations where you can't deploy to the root of the domain, but a sub directory. @@ -101,7 +103,7 @@ createBrowserRouter(routes, { ; // results in ``` -## `future` +## `opts.future` An optional set of [Future Flags][api-development-strategy] to enable for this Router. We recommend opting into newly released future flags sooner rather than later to ease your eventual migration to v7. @@ -125,7 +127,7 @@ The following future flags are currently available: | [`v7_relativeSplatPath`][relativesplatpath] | Fix buggy relative path resolution in splat routes | | `unstable_skipActionErrorRevalidation` | Do not revalidate by default if the action returns a 4xx/5xx `Response` | -## `hydrationData` +## `opts.hydrationData` When [Server-Rendering][ssr] and [opting-out of automatic hydration][hydrate-false], the `hydrationData` option allows you to pass in hydration data from your server-render. This will almost always be a subset of data from the `StaticHandlerContext` value you get back from [handler.query][query]: @@ -182,7 +184,7 @@ const router = createBrowserRouter( ); ``` -## `unstable_dataStrategy` +## `opts.unstable_dataStrategy` This is a low-level API intended for advanced use-cases. This overrides React Router's internal handling of `loader`/`action` execution, and if done incorrectly will break your app code. Please use with caution and perform the appropriate testing. @@ -228,6 +230,8 @@ interface HandlerResult { } ``` +### Overview + `unstable_dataStrategy` receives the same arguments as a `loader`/`action` (`request`, `params`) but it also receives a `matches` array which is an array of the matched routes where each match is extended with 2 new fields for use in the data strategy function: - **`match.resolve`** - An async function that will resolve any `route.lazy` implementations and execute the route's handler (if necessary), returning a `HandlerResult` @@ -359,7 +363,156 @@ let router = createBrowserRouter(routes, { }); ``` -## `window` +## `opts.unstable_patchRoutesOnMiss` + +This API is marked "unstable" so it is subject to breaking API changes in minor releases + +By default, React Router wants you to provide a full route tree up front via `createBrowserRouter(routes)`. This allows React Router to perform synchronous route matching, execute loaders, and then render route components in the most optimistic manner without introducing waterfalls. The tradeoff is that your initial JS bundle is larger by definition - which may slow down application start-up times as your application grows. + +To combat this, we introduced [`route.lazy`][route-lazy] in [v6.9.0][6-9-0] which let's you lazily load the route _implementation_ (`loader`, `Component`, etc.) while still providing the route _definition_ aspects up front (`path`, `index`, etc.). This is a good middle ground because React Router still knows about your routes up front and can perform synchronous route matching, but then delay loading any of the route implementation aspects until the route is actually navigated to. + +In some cases, even this doesn't go far enough. For very large applications, providing all route definitions up front can be prohibitively expensive. Additionally, it might not even be possible to provide all route definitions up front in certain Micro-Frontend or Module-Federation architectures. + +This is where `unstable_patchRoutesOnMiss` comes in ([RFC][fog-of-war-rfc]). This API is for advanced use-cases where you are unable to provide the full route tree up-front and need a way to lazily "discover" portions of the route tree at runtime. This feature is often referred to as ["Fog of War"][fog-of-war] because similar to how video games expand the "world" as you move around - the router would be expanding its routing tree as the user navigated around the app - but would only ever end up loading portions of the tree that the user visited. + +### Type Declaration + +```ts +export interface unstable_PatchRoutesOnMissFunction { + (opts: { + path: string; + matches: RouteMatch[]; + patch: ( + routeId: string | null, + children: RouteObject[] + ) => void; + }): void | Promise; +} +``` + +### Overview + +`unstable_patchRoutesOnMiss` will be called anytime React Router is unable to match a `path`. The arguments include the `path`, any partial `matches`, and a `patch` function you can call to patch new routes into the tree at a specific location. This method is executed during the `loading` portion of the navigation for `GET` requests and during the `submitting` portion of the navigation for non-`GET` requests. + +**Patching children into an existing route** + +```jsx +const router = createBrowserRouter( + [ + { + id: "root", + path: "/", + Component: RootComponent, + }, + ], + { + async unstable_patchRoutesOnMiss({ path, patch }) { + if (path === "/a") { + // Load/patch the `a` route as a child of the route with id `root` + let route = await getARoute(); // { path: 'a', Component: A } + patch("root", [route]); + } + }, + } +); +``` + +In the above example, if the user clicks a clink to `/a`, React Router won't be able to match it initially and will call `patchRoutesOnMiss` with `/a` and a `matches` array containing the root route match. By calling `patch`, it the `a` route will be added to the route tree and React Router will perform matching again. This time it will successfully match the `/a` path and the navigation will complete successfully. + +**Patching new root-level routes** + +If you need to patch a new route to the top of the tree (i.e., it doesn't have a parent), you can pass `null` as the `routeId`: + +```jsx +const router = createBrowserRouter( + [ + { + id: "root", + path: "/", + Component: RootComponent, + }, + ], + { + async unstable_patchRoutesOnMiss({ path, patch }) { + if (path === "/root-sibling") { + // Load/patch the `/sibling` route at the top + let route = await getRootSiblingRoute(); // { path: '/sibling', Component: Sibling } + patch(null, [route]); + } + }, + } +); +``` + +**Patching sub-trees asyncronously** + +You can also perform asynchronous matching to lazily fetch entire sections of your application: + +```jsx +let router = createBrowserRouter( + [ + { + path: "/", + Component: Home, + }, + { + id: "dashboard", + path: "/dashboard", + }, + { + id: "account", + path: "/account", + }, + ], + { + async unstable_patchRoutesOnMiss({ path, patch }) { + if (path.startsWith("/dashboard")) { + let children = await import("./dashboard"); + patch("dashboard", children); + } + if (path.startsWith("/account")) { + let children = await import("./account"); + patch("account", children); + } + }, + } +); +``` + +**Co-locating route discovery with route definition** + +If you don't wish to perform your own pseudo-matching, you can leverage the partial `matches` array and the `handle` field on a route to keep the children definitions co-located: + +```jsx +let router = createBrowserRouter([ + { + path: "/", + Component: Home, + }, + { + path: "/dashboard", + handle: { + lazyChildren: () => import('./dashboard'); + } + }, + { + path: "/account", + handle: { + lazyChildren: () => import('./account'); + } + }, +], { + async unstable_patchRoutesOnMiss({ matches, patch }) { + let leafRoute = matches[matches.length - 1]?.route; + if (leafRoute?.handle?.lazyChildren) { + let children = await leafRoute.handle.lazyChildren(); + patch(leafRoute.id, children); + } + } +}); +``` + +## `opts.window` Useful for environments like browser devtool plugins or testing to use a different window than the global `window`. @@ -377,4 +530,7 @@ Useful for environments like browser devtool plugins or testing to use a differe [clientloader]: https://remix.run/route/client-loader [hydratefallback]: ../route/hydrate-fallback-element [relativesplatpath]: ../hooks/use-resolved-path#splat-paths -[currying]: https://stackoverflow.com/questions/36314/what-is-currying +[route-lazy]: ../route/lazy +[6-9-0]: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v690 +[fog-of-war]: https://en.wikipedia.org/wiki/Fog_of_war +[fog-of-war-rfc]: https://github.com/remix-run/react-router/discussions/11113 diff --git a/package.json b/package.json index af7348a3d1..8ce0ca6a44 100644 --- a/package.json +++ b/package.json @@ -105,19 +105,19 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "52.8 kB" + "none": "56.3 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "14.8 kB" + "none": "14.9 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "17.21 kB" + "none": "17.3 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "17.1 kB" + "none": "17.2 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "23.5 kB" + "none": "23.6 kB" } }, "pnpm": { diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 1229fcb453..0718b318c0 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -15,6 +15,7 @@ import type { RouteObject, RouterProviderProps, To, + unstable_PatchRoutesOnMissFunction, } from "react-router"; import { Router, @@ -151,6 +152,7 @@ export type { To, UIMatch, unstable_HandlerResult, + unstable_PatchRoutesOnMissFunction, } from "react-router"; export { AbortedDeferredError, @@ -257,6 +259,7 @@ interface DOMRouterOpts { future?: Partial>; hydrationData?: HydrationState; unstable_dataStrategy?: unstable_DataStrategyFunction; + unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction; window?: Window; } @@ -275,6 +278,7 @@ export function createBrowserRouter( routes, mapRouteProperties, unstable_dataStrategy: opts?.unstable_dataStrategy, + unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss, window: opts?.window, }).initialize(); } @@ -294,6 +298,7 @@ export function createHashRouter( routes, mapRouteProperties, unstable_dataStrategy: opts?.unstable_dataStrategy, + unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss, window: opts?.window, }).initialize(); } diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 3d12d02c89..7938be3f3b 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -376,6 +376,9 @@ export function createStaticRouter( deleteBlocker() { throw msg("deleteBlocker"); }, + patchRoutes() { + throw msg("patchRoutes"); + }, _internalFetchControllers: new Map(), _internalActiveDeferreds: new Map(), _internalSetRoutes() { diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index db9dd488ad..9f1a9e8b71 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -3098,7 +3098,6 @@ describe("createMemoryRouter", () => { ); - console.log(getHtml(container)); expect(getHtml(container)).toMatchInlineSnapshot(` "

diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index b6d85af431..5f417b83dd 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -32,6 +32,7 @@ import type { To, UIMatch, unstable_HandlerResult, + unstable_AgnosticPatchRoutesOnMissFunction, } from "@remix-run/router"; import { AbortedDeferredError, @@ -288,6 +289,9 @@ function mapRouteProperties(route: RouteObject) { return updates; } +export interface unstable_PatchRoutesOnMissFunction + extends unstable_AgnosticPatchRoutesOnMissFunction {} + export function createMemoryRouter( routes: RouteObject[], opts?: { @@ -297,6 +301,7 @@ export function createMemoryRouter( initialEntries?: InitialEntry[]; initialIndex?: number; unstable_dataStrategy?: unstable_DataStrategyFunction; + unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction; } ): RemixRouter { return createRouter({ @@ -313,6 +318,7 @@ export function createMemoryRouter( routes, mapRouteProperties, unstable_dataStrategy: opts?.unstable_dataStrategy, + unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss, }).initialize(); } diff --git a/packages/router/__tests__/lazy-discovery-test.ts b/packages/router/__tests__/lazy-discovery-test.ts new file mode 100644 index 0000000000..690a586014 --- /dev/null +++ b/packages/router/__tests__/lazy-discovery-test.ts @@ -0,0 +1,1607 @@ +import type { AgnosticDataRouteObject, Router } from "../index"; +import { createMemoryHistory, createRouter } from "../index"; +import { ErrorResponseImpl } from "../utils"; +import { createDeferred, createFormData, tick } from "./utils/utils"; + +let router: Router; + +function last(array: any[]) { + return array[array.length - 1]; +} + +describe("Lazy Route Discovery (Fog of War)", () => { + afterEach(() => { + router.dispose(); + // @ts-expect-error + router = null; + }); + + it("discovers child route at a depth of 1 (GET navigation)", async () => { + let childrenDfd = createDeferred(); + let loaderDfd = createDeferred(); + let childLoaderDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + loader: () => loaderDfd.promise, + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + let children = await childrenDfd.promise; + patch("parent", children); + }, + }); + + router.navigate("/parent/child"); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + loaderDfd.resolve("PARENT"); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childrenDfd.resolve([ + { + id: "child", + path: "child", + loader: () => childLoaderDfd.promise, + }, + ]); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childLoaderDfd.resolve("CHILD"); + await tick(); + + expect(router.state.location.pathname).toBe("/parent/child"); + expect(router.state.loaderData).toEqual({ + parent: "PARENT", + child: "CHILD", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "parent", + "child", + ]); + }); + + it("discovers child routes at a depth >1 (GET navigation)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ patch, matches }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } + + if (last(matches).route.id === "b") { + patch("b", [ + { + id: "c", + path: "c", + async loader() { + await tick(); + return "C"; + }, + }, + ]); + } + }, + }); + + await router.navigate("/a/b/c"); + expect(router.state.location.pathname).toBe("/a/b/c"); + expect(router.state.loaderData).toEqual({ + c: "C", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("discovers child route at a depth of 1 (POST navigation)", async () => { + let childrenDfd = createDeferred(); + let loaderDfd = createDeferred(); + let childActionDfd = createDeferred(); + let childLoaderDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + loader: () => loaderDfd.promise, + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + let children = await childrenDfd.promise; + patch("parent", children); + }, + }); + + router.navigate("/parent/child", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(router.state.navigation).toMatchObject({ + state: "submitting", + location: { pathname: "/parent/child" }, + }); + + childrenDfd.resolve([ + { + id: "child", + path: "child", + action: () => childActionDfd.promise, + loader: () => childLoaderDfd.promise, + }, + ]); + expect(router.state.navigation).toMatchObject({ + state: "submitting", + location: { pathname: "/parent/child" }, + }); + + childActionDfd.resolve("CHILD ACTION"); + await tick(); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + expect(router.state.actionData?.child).toBe("CHILD ACTION"); + + loaderDfd.resolve("PARENT"); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childLoaderDfd.resolve("CHILD"); + await tick(); + + expect(router.state).toMatchObject({ + location: { pathname: "/parent/child" }, + actionData: { + child: "CHILD ACTION", + }, + loaderData: { + parent: "PARENT", + child: "CHILD", + }, + navigation: { state: "idle" }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "parent", + "child", + ]); + }); + + it("discovers child routes at a depth >1 (POST navigation)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ patch, matches }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } + + if (last(matches).route.id === "b") { + patch("b", [ + { + id: "c", + path: "c", + async action() { + await tick(); + return "C ACTION"; + }, + async loader() { + await tick(); + return "C"; + }, + }, + ]); + } + }, + }); + + await router.navigate("/a/b/c", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b/c" }, + actionData: { + c: "C ACTION", + }, + loaderData: { + c: "C", + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("reuses promises", async () => { + let aDfd = createDeferred(); + let calls: string[][] = []; + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ path, matches, patch }) { + let routeId = last(matches).route.id; + calls.push([path, routeId]); + patch("a", await aDfd.promise); + }, + }); + + router.navigate("/a/b"); + await tick(); + expect(router.state).toMatchObject({ + navigation: { state: "loading", location: { pathname: "/a/b" } }, + }); + expect(calls).toEqual([["/a/b", "a"]]); + + router.navigate("/a/b", { + formMethod: "POST", + formData: createFormData({}), + }); + await tick(); + expect(router.state).toMatchObject({ + navigation: { state: "submitting", location: { pathname: "/a/b" } }, + }); + // Didn't call again for the same path + expect(calls).toEqual([["/a/b", "a"]]); + + aDfd.resolve([ + { + id: "b", + path: "b", + action: () => "A ACTION", + loader: () => "A", + }, + ]); + await tick(); + expect(router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/a/b" }, + }); + expect(calls).toEqual([["/a/b", "a"]]); + }); + + it("handles interruptions", async () => { + let aDfd = createDeferred(); + let bDfd = createDeferred(); + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ path, matches, patch }) { + let routeId = last(matches).route.id; + if (!path) { + return; + } + if (routeId === "a") { + patch("a", await aDfd.promise); + } else if (routeId === "b") { + patch("b", await bDfd.promise); + } + }, + }); + + router.navigate("/a/b/c"); + await tick(); + expect(router.state).toMatchObject({ + navigation: { state: "loading", location: { pathname: "/a/b/c" } }, + }); + + aDfd.resolve([ + { + id: "b", + path: "b", + }, + ]); + await tick(); + expect(router.state).toMatchObject({ + navigation: { state: "loading", location: { pathname: "/a/b/c" } }, + }); + + router.navigate("/a/b/d"); + await tick(); + expect(router.state).toMatchObject({ + navigation: { state: "loading", location: { pathname: "/a/b/d" } }, + }); + + bDfd.resolve([ + { + id: "c", + path: "c", + loader() { + return "C"; + }, + }, + { + id: "d", + path: "d", + loader() { + return "D"; + }, + }, + ]); + await tick(); + + expect(router.state.location.pathname).toBe("/a/b/d"); + expect(router.state.loaderData).toEqual({ + d: "D", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "d", + ]); + }); + + it("allows folks to implement at the route level via handle.children()", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + handle: { + async loadChildren() { + await tick(); + return [ + { + id: "b", + path: "b", + handle: { + async loadChildren() { + await tick(); + return [ + { + id: "c", + path: "c", + async loader() { + await tick(); + return "C"; + }, + }, + ]; + }, + }, + }, + ]; + }, + }, + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + let leafRoute = last(matches).route; + patch(leafRoute.id, await leafRoute.handle.loadChildren?.()); + }, + }); + + await router.navigate("/a/b/c"); + expect(router.state.location.pathname).toBe("/a/b/c"); + expect(router.state.loaderData).toEqual({ + c: "C", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("discovers child routes through pathless routes", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "pathless", + path: "", + }, + ]); + } else if (last(matches).route.id === "pathless") { + patch("pathless", [ + { + id: "b", + path: "b", + async loader() { + await tick(); + return "B"; + }, + }, + ]); + } + }, + }); + + await router.navigate("/a/b"); + expect(router.state.location.pathname).toBe("/a/b"); + expect(router.state.loaderData).toEqual({ + b: "B", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "pathless", + "b", + ]); + }); + + it("de-prioritizes splat routes in favor of looking for better async matches", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "splat", + path: "*", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } + }, + }); + + await router.navigate("/a/b"); + expect(router.state.location.pathname).toBe("/a/b"); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["a", "b"]); + }); + + it("matches splats when other paths don't pan out", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "splat", + path: "*", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } + }, + }); + + await router.navigate("/a/nope"); + expect(router.state.location.pathname).toBe("/a/nope"); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["splat"]); + }); + + it("discovers routes during initial hydration", async () => { + let childrenDfd = createDeferred(); + let loaderDfd = createDeferred(); + let childLoaderDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/parent/child"] }), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + loader: () => loaderDfd.promise, + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + let children = await childrenDfd.promise; + patch("parent", children); + }, + }); + router.initialize(); + + expect(router.state.initialized).toBe(false); + + loaderDfd.resolve("PARENT"); + expect(router.state.initialized).toBe(false); + + childrenDfd.resolve([ + { + id: "child", + path: "child", + loader: () => childLoaderDfd.promise, + }, + ]); + expect(router.state.initialized).toBe(false); + + childLoaderDfd.resolve("CHILD"); + await tick(); + + expect(router.state.initialized).toBe(true); + expect(router.state.location.pathname).toBe("/parent/child"); + expect(router.state.loaderData).toEqual({ + parent: "PARENT", + child: "CHILD", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "parent", + "child", + ]); + }); + + it("discovers new root routes", async () => { + let childrenDfd = createDeferred(); + let childLoaderDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + path: "/parent", + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + patch(null, await childrenDfd.promise); + }, + }); + + router.navigate("/parent/child"); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childrenDfd.resolve([ + { + id: "parent-child", + path: "/parent/child", + loader: () => childLoaderDfd.promise, + }, + ]); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childLoaderDfd.resolve("CHILD"); + await tick(); + + expect(router.state.location.pathname).toBe("/parent/child"); + expect(router.state.loaderData).toEqual({ + "parent-child": "CHILD", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "parent-child", + ]); + }); + + it("lets you patch elsewhere in the tree (dynamic param)", async () => { + let childrenDfd = createDeferred(); + let childLoaderDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + id: "root", + path: "/", + }, + { + id: "param", + path: "/:param", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + // We matched for the param but we want to patch in under root + expect(matches.length).toBe(1); + expect(matches[0].route.id).toBe("param"); + patch("root", await childrenDfd.promise); + }, + }); + + router.navigate("/parent/child"); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childrenDfd.resolve([ + { + id: "parent-child", + path: "/parent/child", + loader: () => childLoaderDfd.promise, + }, + ]); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childLoaderDfd.resolve("CHILD"); + await tick(); + + expect(router.state.location.pathname).toBe("/parent/child"); + expect(router.state.loaderData).toEqual({ + "parent-child": "CHILD", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "root", + "parent-child", + ]); + }); + + it("lets you patch elsewhere in the tree (splat)", async () => { + let childrenDfd = createDeferred(); + let childLoaderDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + id: "other", + path: "/other", + }, + { + id: "splat", + path: "*", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + // We matched for the splat but we want to patch in at the top + expect(matches.length).toBe(1); + expect(matches[0].route.id).toBe("splat"); + let children = await childrenDfd.promise; + patch(null, children); + }, + }); + + router.navigate("/parent/child"); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childrenDfd.resolve([ + { + id: "parent-child", + path: "/parent/child", + loader: () => childLoaderDfd.promise, + }, + ]); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childLoaderDfd.resolve("CHILD"); + await tick(); + + expect(router.state.location.pathname).toBe("/parent/child"); + expect(router.state.loaderData).toEqual({ + "parent-child": "CHILD", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "parent-child", + ]); + }); + + it("works when there are no partial matches", async () => { + let childrenDfd = createDeferred(); + let childLoaderDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/nope", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + expect(matches.length).toBe(0); + let children = await childrenDfd.promise; + patch(null, children); + }, + }); + + router.navigate("/parent/child"); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childrenDfd.resolve([ + { + id: "parent-child", + path: "/parent/child", + loader: () => childLoaderDfd.promise, + }, + ]); + expect(router.state.navigation).toMatchObject({ + state: "loading", + location: { pathname: "/parent/child" }, + }); + + childLoaderDfd.resolve("CHILD"); + await tick(); + + expect(router.state.location.pathname).toBe("/parent/child"); + expect(router.state.loaderData).toEqual({ + "parent-child": "CHILD", + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "parent-child", + ]); + }); + + describe("errors", () => { + it("lazy 404s (GET navigation)", async () => { + let childrenDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + let children = await childrenDfd.promise; + patch("parent", children); + }, + }); + + router.navigate("/parent/junk"); + expect(router.state.navigation).toMatchObject({ + state: "loading", + }); + + childrenDfd.resolve([{ id: "child", path: "child" }]); + await tick(); + + expect(router.state).toMatchObject({ + location: { pathname: "/parent/junk" }, + loaderData: {}, + errors: { + "0": new ErrorResponseImpl( + 404, + "Not Found", + new Error('No route matches URL "/parent/junk"'), + true + ), + }, + }); + expect(router.state.matches).toEqual([ + { + params: {}, + pathname: "", + pathnameBase: "", + route: { + children: undefined, + hasErrorBoundary: false, + id: "0", + path: "/", + }, + }, + ]); + }); + + it("lazy 404s (POST navigation)", async () => { + let childrenDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + let children = await childrenDfd.promise; + patch("parent", children); + }, + }); + + router.navigate("/parent/junk", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(router.state.navigation).toMatchObject({ + state: "submitting", + }); + + childrenDfd.resolve([{ id: "child", path: "child" }]); + await tick(); + + expect(router.state).toMatchObject({ + location: { pathname: "/parent/junk" }, + actionData: null, + loaderData: {}, + errors: { + "0": new ErrorResponseImpl( + 404, + "Not Found", + new Error('No route matches URL "/parent/junk"'), + true + ), + }, + }); + expect(router.state.matches).toEqual([ + { + params: {}, + pathname: "", + pathnameBase: "", + route: { + children: undefined, + hasErrorBoundary: false, + id: "0", + path: "/", + }, + }, + ]); + }); + + it("errors thrown at lazy boundary route (GET navigation)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } else if (last(matches).route.id === "b") { + await tick(); + patch("b", [ + { + id: "c", + path: "c", + hasErrorBoundary: true, + async loader() { + await tick(); + throw new Error("C ERROR"); + }, + }, + ]); + } + }, + }); + + await router.navigate("/a/b/c"); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b/c" }, + loaderData: {}, + errors: { + c: new Error("C ERROR"), + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("errors bubbled to lazy parent route (GET navigation)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + hasErrorBoundary: true, + }, + ]); + } else if (last(matches).route.id === "b") { + await tick(); + patch("b", [ + { + id: "c", + path: "c", + async loader() { + await tick(); + throw new Error("C ERROR"); + }, + }, + ]); + } + }, + }); + + await router.navigate("/a/b/c"); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b/c" }, + loaderData: {}, + errors: { + b: new Error("C ERROR"), + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("errors bubbled when no boundary exists (GET navigation)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } else if (last(matches).route.id === "b") { + await tick(); + patch("b", [ + { + id: "c", + path: "c", + async loader() { + await tick(); + throw new Error("C ERROR"); + }, + }, + ]); + } + }, + }); + + await router.navigate("/a/b/c"); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b/c" }, + loaderData: {}, + errors: { + a: new Error("C ERROR"), + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("errors thrown at lazy boundary route (POST navigation)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } else if (last(matches).route.id === "b") { + await tick(); + patch("b", [ + { + id: "c", + path: "c", + hasErrorBoundary: true, + async action() { + await tick(); + throw new Error("C ERROR"); + }, + }, + ]); + } + }, + }); + + await router.navigate("/a/b/c", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b/c" }, + actionData: null, + loaderData: {}, + errors: { + c: new Error("C ERROR"), + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("errors bubbled to lazy parent route (POST navigation)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + hasErrorBoundary: true, + }, + ]); + } else if (last(matches).route.id === "b") { + await tick(); + patch("b", [ + { + id: "c", + path: "c", + async action() { + await tick(); + throw new Error("C ERROR"); + }, + }, + ]); + } + }, + }); + + await router.navigate("/a/b/c", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b/c" }, + actionData: null, + loaderData: {}, + errors: { + b: new Error("C ERROR"), + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("errors bubbled when no boundary exists (POST navigation)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } else if (last(matches).route.id === "b") { + await tick(); + patch("b", [ + { + id: "c", + path: "c", + async action() { + await tick(); + throw new Error("C ERROR"); + }, + }, + ]); + } + }, + }); + + await router.navigate("/a/b/c", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b/c" }, + actionData: null, + loaderData: {}, + errors: { + a: new Error("C ERROR"), + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("handles errors thrown from children() (GET navigation)", async () => { + let shouldThrow = true; + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + id: "index", + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + await tick(); + if (shouldThrow) { + shouldThrow = false; + throw new Error("broke!"); + } + patch("a", [ + { + id: "b", + path: "b", + loader() { + return "B"; + }, + }, + ]); + }, + }); + + await router.navigate("/a/b"); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b" }, + actionData: null, + loaderData: {}, + errors: { + a: new ErrorResponseImpl( + 400, + "Bad Request", + new Error( + 'Unable to match URL "/a/b" - the `children()` function for route `a` threw the following error:\nError: broke!' + ), + true + ), + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["a"]); + + await router.navigate("/"); + expect(router.state).toMatchObject({ + location: { pathname: "/" }, + actionData: null, + loaderData: {}, + errors: null, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["index"]); + + await router.navigate("/a/b"); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b" }, + actionData: null, + loaderData: { + b: "B", + }, + errors: null, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["a", "b"]); + }); + + it("handles errors thrown from children() (POST navigation)", async () => { + let shouldThrow = true; + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + id: "index", + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + await tick(); + if (shouldThrow) { + shouldThrow = false; + throw new Error("broke!"); + } + patch("a", [ + { + id: "b", + path: "b", + action() { + return "B"; + }, + }, + ]); + }, + }); + + await router.navigate("/a/b", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b" }, + actionData: null, + loaderData: {}, + errors: { + a: new ErrorResponseImpl( + 400, + "Bad Request", + new Error( + 'Unable to match URL "/a/b" - the `children()` function for route `a` threw the following error:\nError: broke!' + ), + true + ), + }, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["a"]); + + await router.navigate("/"); + expect(router.state).toMatchObject({ + location: { pathname: "/" }, + actionData: null, + loaderData: {}, + errors: null, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["index"]); + + await router.navigate("/a/b", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(router.state).toMatchObject({ + location: { pathname: "/a/b" }, + actionData: { + b: "B", + }, + loaderData: {}, + errors: null, + }); + expect(router.state.matches.map((m) => m.route.id)).toEqual(["a", "b"]); + }); + }); + + describe("fetchers", () => { + it("discovers child route at a depth of 1 (fetcher.load)", async () => { + let childrenDfd = createDeferred(); + let childLoaderDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + let children = await childrenDfd.promise; + patch("parent", children); + }, + }); + + let key = "key"; + router.fetch(key, "0", "/parent/child"); + expect(router.getFetcher(key).state).toBe("loading"); + + childrenDfd.resolve([ + { + id: "child", + path: "child", + loader: () => childLoaderDfd.promise, + }, + ]); + expect(router.getFetcher(key).state).toBe("loading"); + + childLoaderDfd.resolve("CHILD"); + await tick(); + + expect(router.getFetcher(key).state).toBe("idle"); + expect(router.getFetcher(key).data).toBe("CHILD"); + }); + + it("discovers child routes at a depth >1 (fetcher.load)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } else if (last(matches).route.id === "b") { + patch("b", [ + { + id: "c", + path: "c", + async loader() { + await tick(); + return "C"; + }, + }, + ]); + } + }, + }); + + let key = "key"; + await router.fetch(key, "0", "/a/b/c"); + // Needed for now since router.fetch is not async until v7 + await new Promise((r) => setTimeout(r, 10)); + expect(router.getFetcher(key).state).toBe("idle"); + expect(router.getFetcher(key).data).toBe("C"); + }); + + it("discovers child route at a depth of 1 (fetcher.submit)", async () => { + let childrenDfd = createDeferred(); + let childActionDfd = createDeferred(); + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + }, + ], + async unstable_patchRoutesOnMiss({ patch }) { + let children = await childrenDfd.promise; + patch("parent", children); + }, + }); + + let key = "key"; + router.fetch(key, "0", "/parent/child", { + formMethod: "post", + formData: createFormData({}), + }); + expect(router.getFetcher(key).state).toBe("submitting"); + + childrenDfd.resolve([ + { + id: "child", + path: "child", + action: () => childActionDfd.promise, + }, + ]); + expect(router.getFetcher(key).state).toBe("submitting"); + + childActionDfd.resolve("CHILD"); + await tick(); + + expect(router.getFetcher(key).state).toBe("idle"); + expect(router.getFetcher(key).data).toBe("CHILD"); + }); + + it("discovers child routes at a depth >1 (fetcher.submit)", async () => { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "a", + path: "a", + }, + ], + async unstable_patchRoutesOnMiss({ matches, patch }) { + await tick(); + if (last(matches).route.id === "a") { + patch("a", [ + { + id: "b", + path: "b", + }, + ]); + } else if (last(matches).route.id === "b") { + patch("b", [ + { + id: "c", + path: "c", + async action() { + await tick(); + return "C ACTION"; + }, + }, + ]); + } + }, + }); + + let key = "key"; + await router.fetch(key, "0", "/a/b/c", { + formMethod: "POST", + formData: createFormData({}), + }); + // Needed for now since router.fetch is not async until v7 + await new Promise((r) => setTimeout(r, 10)); + expect(router.getFetcher(key).state).toBe("idle"); + expect(router.getFetcher(key).data).toBe("C ACTION"); + }); + }); +}); diff --git a/packages/router/__tests__/lazy-test.ts b/packages/router/__tests__/lazy-test.ts index e631f03d41..20642ba907 100644 --- a/packages/router/__tests__/lazy-test.ts +++ b/packages/router/__tests__/lazy-test.ts @@ -1,4 +1,3 @@ -/* eslint-disable jest/valid-title */ import { createMemoryHistory, createRouter, diff --git a/packages/router/index.ts b/packages/router/index.ts index 8a07c5e27b..f5f984d57f 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -23,6 +23,7 @@ export type { LoaderFunctionArgs, ParamParseKey, Params, + AgnosticPatchRoutesOnMissFunction as unstable_AgnosticPatchRoutesOnMissFunction, PathMatch, PathParam, PathPattern, diff --git a/packages/router/router.ts b/packages/router/router.ts index b72cb70091..9fbca292c0 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -35,6 +35,7 @@ import type { UIMatch, V7_FormMethod, V7_MutationFormMethod, + AgnosticPatchRoutesOnMissFunction, } from "./utils"; import { ErrorResponseImpl, @@ -47,6 +48,7 @@ import { isRouteErrorResponse, joinPaths, matchRoutes, + matchRoutesImpl, resolveTo, stripBasename, } from "./utils"; @@ -242,6 +244,16 @@ export interface Router { */ deleteBlocker(key: string): void; + /** + * @internal + * PRIVATE DO NOT USE + * + * Patch additional children routes into an existing parent route + * @param routeId The parent route id + * @param children The additional children routes + */ + patchRoutes(routeId: string | null, children: AgnosticRouteObject[]): void; + /** * @internal * PRIVATE - DO NOT USE @@ -377,6 +389,7 @@ export interface RouterInit { future?: Partial; hydrationData?: HydrationState; window?: Window; + unstable_patchRoutesOnMiss?: AgnosticPatchRoutesOnMissFunction; unstable_dataStrategy?: DataStrategyFunction; } @@ -631,6 +644,10 @@ interface ShortCircuitable { type PendingActionResult = [string, SuccessResult | ErrorResult]; interface HandleActionResult extends ShortCircuitable { + /** + * Route matches which may have been updated from fog of war discovery + */ + matches?: RouterState["matches"]; /** * Tuple for the returned or thrown value from the current action. The routeId * is the action route for success and the bubbled boundary route for errors. @@ -639,6 +656,10 @@ interface HandleActionResult extends ShortCircuitable { } interface HandleLoadersResult extends ShortCircuitable { + /** + * Route matches which may have been updated from fog of war discovery + */ + matches?: RouterState["matches"]; /** * loaderData returned from the current set of loaders */ @@ -775,6 +796,8 @@ export function createRouter(init: RouterInit): Router { let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined; let basename = init.basename || "/"; let dataStrategyImpl = init.unstable_dataStrategy || defaultDataStrategy; + let patchRoutesOnMissImpl = init.unstable_patchRoutesOnMiss; + // Config driven behavior flags let future: FutureConfig = { v7_fetcherPersist: false, @@ -806,7 +829,7 @@ export function createRouter(init: RouterInit): Router { let initialMatches = matchRoutes(dataRoutes, init.history.location, basename); let initialErrors: RouteData | null = null; - if (initialMatches == null) { + if (initialMatches == null && !patchRoutesOnMissImpl) { // If we do not match a user-provided-route, fall back to the root // to allow the error boundary to take over let error = getInternalRouterError(404, { @@ -818,13 +841,15 @@ export function createRouter(init: RouterInit): Router { } let initialized: boolean; - let hasLazyRoutes = initialMatches.some((m) => m.route.lazy); - let hasLoaders = initialMatches.some((m) => m.route.loader); - if (hasLazyRoutes) { + if (!initialMatches) { + // We need to run patchRoutesOnMiss in initialize() + initialized = false; + initialMatches = []; + } else if (initialMatches.some((m) => m.route.lazy)) { // All initialMatches need to be loaded before we're ready. If we have lazy // functions around still then we'll need to run them in initialize() initialized = false; - } else if (!hasLoaders) { + } else if (!initialMatches.some((m) => m.route.loader)) { // If we've got no loaders to run, then we're good to go initialized = true; } else if (future.v7_partialHydration) { @@ -963,6 +988,13 @@ export function createRouter(init: RouterInit): Router { // we don't need to update UI state if they change let blockerFunctions = new Map(); + // Map of pending patchRoutesOnMiss() promises (keyed by path/matches) so + // that we only kick them off once for a given combo + let pendingPatchRoutes = new Map< + string, + ReturnType + >(); + // Flag to ignore the next history update, so we can revert the URL change on // a POP navigation that was blocked by the user without touching router state let ignoreNextHistoryUpdate = false; @@ -1455,13 +1487,16 @@ export function createRouter(init: RouterInit): Router { let matches = matchRoutes(routesToUse, location, basename); let flushSync = (opts && opts.flushSync) === true; + let fogOfWar = checkFogOfWar(matches, routesToUse, location.pathname); + if (fogOfWar.active && fogOfWar.matches) { + matches = fogOfWar.matches; + } + // Short circuit with a 404 on the root error boundary if we match nothing if (!matches) { - let error = getInternalRouterError(404, { pathname: location.pathname }); - let { matches: notFoundMatches, route } = - getShortCircuitMatches(routesToUse); - // Cancel all pending deferred on 404s since we don't keep any routes - cancelActiveDeferreds(); + let { error, notFoundMatches, route } = handleNavigational404( + location.pathname + ); completeNavigation( location, { @@ -1522,6 +1557,7 @@ export function createRouter(init: RouterInit): Router { location, opts.submission, matches, + fogOfWar.active, { replace: opts.replace, flushSync } ); @@ -1529,9 +1565,34 @@ export function createRouter(init: RouterInit): Router { return; } + // If we received a 404 from handleAction, it's because we couldn't lazily + // discover the destination route so we don't want to call loaders + if (actionResult.pendingActionResult) { + let [routeId, result] = actionResult.pendingActionResult; + if ( + isErrorResult(result) && + isRouteErrorResponse(result.error) && + result.error.status === 404 + ) { + pendingNavigationController = null; + + completeNavigation(location, { + matches: actionResult.matches, + loaderData: {}, + errors: { + [routeId]: result.error, + }, + }); + return; + } + } + + matches = actionResult.matches || matches; pendingActionResult = actionResult.pendingActionResult; loadingNavigation = getLoadingNavigation(location, opts.submission); flushSync = false; + // No need to do fog of war matching again on loader execution + fogOfWar.active = false; // Create a GET request for the loaders request = createClientSideRequest( @@ -1542,10 +1603,16 @@ export function createRouter(init: RouterInit): Router { } // Call loaders - let { shortCircuited, loaderData, errors } = await handleLoaders( + let { + shortCircuited, + matches: updatedMatches, + loaderData, + errors, + } = await handleLoaders( request, location, matches, + fogOfWar.active, loadingNavigation, opts && opts.submission, opts && opts.fetcherSubmission, @@ -1565,7 +1632,7 @@ export function createRouter(init: RouterInit): Router { pendingNavigationController = null; completeNavigation(location, { - matches, + matches: updatedMatches || matches, ...getActionDataForCommit(pendingActionResult), loaderData, errors, @@ -1579,6 +1646,7 @@ export function createRouter(init: RouterInit): Router { location: Location, submission: Submission, matches: AgnosticDataRouteMatch[], + isFogOfWar: boolean, opts: { replace?: boolean; flushSync?: boolean } = {} ): Promise { interruptActiveLoads(); @@ -1587,6 +1655,48 @@ export function createRouter(init: RouterInit): Router { let navigation = getSubmittingNavigation(location, submission); updateState({ navigation }, { flushSync: opts.flushSync === true }); + if (isFogOfWar) { + let discoverResult = await discoverRoutes( + matches, + location.pathname, + request.signal + ); + if (discoverResult.type === "aborted") { + return { shortCircuited: true }; + } else if (discoverResult.type === "error") { + let { error, notFoundMatches, route } = handleDiscoverRouteError( + location.pathname, + discoverResult + ); + return { + matches: notFoundMatches, + pendingActionResult: [ + route.id, + { + type: ResultType.error, + error, + }, + ], + }; + } else if (!discoverResult.matches) { + let { notFoundMatches, error, route } = handleNavigational404( + location.pathname + ); + return { + matches: notFoundMatches, + pendingActionResult: [ + route.id, + { + type: ResultType.error, + error, + }, + ], + }; + } else { + matches = discoverResult.matches; + } + } + // Call our action and get the result let result: DataResult; let actionMatch = getTargetMatch(matches, location); @@ -1645,20 +1755,23 @@ export function createRouter(init: RouterInit): Router { // to call and will commit it when we complete the navigation let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id); - // By default, all submissions are REPLACE navigations, but if the - // action threw an error that'll be rendered in an errorElement, we fall - // back to PUSH so that the user can use the back button to get back to - // the pre-submission form location to try again + // By default, all submissions to the current location are REPLACE + // navigations, but if the action threw an error that'll be rendered in + // an errorElement, we fall back to PUSH so that the user can use the + // back button to get back to the pre-submission form location to try + // again if ((opts && opts.replace) !== true) { pendingAction = HistoryAction.Push; } return { + matches, pendingActionResult: [boundaryMatch.route.id, result], }; } return { + matches, pendingActionResult: [actionMatch.route.id, result], }; } @@ -1669,6 +1782,7 @@ export function createRouter(init: RouterInit): Router { request: Request, location: Location, matches: AgnosticDataRouteMatch[], + isFogOfWar: boolean, overrideNavigation?: Navigation, submission?: Submission, fetcherSubmission?: Submission, @@ -1688,6 +1802,71 @@ export function createRouter(init: RouterInit): Router { fetcherSubmission || getSubmissionFromNavigation(loadingNavigation); + // If this is an uninterrupted revalidation, we remain in our current idle + // state. If not, we need to switch to our loading state and load data, + // preserving any new action data or existing action data (in the case of + // a revalidation interrupting an actionReload) + // If we have partialHydration enabled, then don't update the state for the + // initial data load since it's not a "navigation" + let shouldUpdateNavigationState = + !isUninterruptedRevalidation && + (!future.v7_partialHydration || !initialHydration); + + // When fog of war is enabled, we enter our `loading` state earlier so we + // can discover new routes during the `loading` state. We skip this if + // we've already run actions since we would have done our matching already. + // If the children() function threw then, we want to proceed with the + // partial matches it discovered. + if (isFogOfWar) { + if (shouldUpdateNavigationState) { + let actionData = getUpdatedActionData(pendingActionResult); + updateState( + { + navigation: loadingNavigation, + ...(actionData !== undefined ? { actionData } : {}), + }, + { + flushSync, + } + ); + } + + let discoverResult = await discoverRoutes( + matches, + location.pathname, + request.signal + ); + + if (discoverResult.type === "aborted") { + return { shortCircuited: true }; + } else if (discoverResult.type === "error") { + let { error, notFoundMatches, route } = handleDiscoverRouteError( + location.pathname, + discoverResult + ); + return { + matches: notFoundMatches, + loaderData: {}, + errors: { + [route.id]: error, + }, + }; + } else if (!discoverResult.matches) { + let { error, notFoundMatches, route } = handleNavigational404( + location.pathname + ); + return { + matches: notFoundMatches, + loaderData: {}, + errors: { + [route.id]: error, + }, + }; + } else { + matches = discoverResult.matches; + } + } + let routesToUse = inFlightDataRoutes || dataRoutes; let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( init.history, @@ -1740,53 +1919,20 @@ export function createRouter(init: RouterInit): Router { return { shortCircuited: true }; } - // If this is an uninterrupted revalidation, we remain in our current idle - // state. If not, we need to switch to our loading state and load data, - // preserving any new action data or existing action data (in the case of - // a revalidation interrupting an actionReload) - // If we have partialHydration enabled, then don't update the state for the - // initial data load since it's not a "navigation" - if ( - !isUninterruptedRevalidation && - (!future.v7_partialHydration || !initialHydration) - ) { - revalidatingFetchers.forEach((rf) => { - let fetcher = state.fetchers.get(rf.key); - let revalidatingFetcher = getLoadingFetcher( - undefined, - fetcher ? fetcher.data : undefined - ); - state.fetchers.set(rf.key, revalidatingFetcher); - }); - - let actionData: Record | null | undefined; - if (pendingActionResult && !isErrorResult(pendingActionResult[1])) { - // This is cast to `any` currently because `RouteData`uses any and it - // would be a breaking change to use any. - // TODO: v7 - change `RouteData` to use `unknown` instead of `any` - actionData = { - [pendingActionResult[0]]: pendingActionResult[1].data as any, - }; - } else if (state.actionData) { - if (Object.keys(state.actionData).length === 0) { - actionData = null; - } else { - actionData = state.actionData; + if (shouldUpdateNavigationState) { + let updates: Partial = {}; + if (!isFogOfWar) { + // Only update navigation/actionNData if we didn't already do it above + updates.navigation = loadingNavigation; + let actionData = getUpdatedActionData(pendingActionResult); + if (actionData !== undefined) { + updates.actionData = actionData; } } - - updateState( - { - navigation: loadingNavigation, - ...(actionData !== undefined ? { actionData } : {}), - ...(revalidatingFetchers.length > 0 - ? { fetchers: new Map(state.fetchers) } - : {}), - }, - { - flushSync, - } - ); + if (revalidatingFetchers.length > 0) { + updates.fetchers = getUpdatedRevalidatingFetchers(revalidatingFetchers); + } + updateState(updates, { flushSync }); } revalidatingFetchers.forEach((rf) => { @@ -1891,12 +2037,46 @@ export function createRouter(init: RouterInit): Router { updatedFetchers || didAbortFetchLoads || revalidatingFetchers.length > 0; return { + matches, loaderData, errors, ...(shouldUpdateFetchers ? { fetchers: new Map(state.fetchers) } : {}), }; } + function getUpdatedActionData( + pendingActionResult: PendingActionResult | undefined + ): Record | null | undefined { + if (pendingActionResult && !isErrorResult(pendingActionResult[1])) { + // This is cast to `any` currently because `RouteData`uses any and it + // would be a breaking change to use any. + // TODO: v7 - change `RouteData` to use `unknown` instead of `any` + return { + [pendingActionResult[0]]: pendingActionResult[1].data as any, + }; + } else if (state.actionData) { + if (Object.keys(state.actionData).length === 0) { + return null; + } else { + return state.actionData; + } + } + } + + function getUpdatedRevalidatingFetchers( + revalidatingFetchers: RevalidatingFetcher[] + ) { + revalidatingFetchers.forEach((rf) => { + let fetcher = state.fetchers.get(rf.key); + let revalidatingFetcher = getLoadingFetcher( + undefined, + fetcher ? fetcher.data : undefined + ); + state.fetchers.set(rf.key, revalidatingFetcher); + }); + return new Map(state.fetchers); + } + // Trigger a fetcher load/submit for the given fetcher key function fetch( key: string, @@ -1928,6 +2108,11 @@ export function createRouter(init: RouterInit): Router { ); let matches = matchRoutes(routesToUse, normalizedPath, basename); + let fogOfWar = checkFogOfWar(matches, routesToUse, normalizedPath); + if (fogOfWar.active && fogOfWar.matches) { + matches = fogOfWar.matches; + } + if (!matches) { setFetcherError( key, @@ -1961,6 +2146,7 @@ export function createRouter(init: RouterInit): Router { path, match, matches, + fogOfWar.active, flushSync, submission ); @@ -1976,6 +2162,7 @@ export function createRouter(init: RouterInit): Router { path, match, matches, + fogOfWar.active, flushSync, submission ); @@ -1989,19 +2176,27 @@ export function createRouter(init: RouterInit): Router { path: string, match: AgnosticDataRouteMatch, requestMatches: AgnosticDataRouteMatch[], + isFogOfWar: boolean, flushSync: boolean, submission: Submission ) { interruptActiveLoads(); fetchLoadMatches.delete(key); - if (!match.route.action && !match.route.lazy) { - let error = getInternalRouterError(405, { - method: submission.formMethod, - pathname: path, - routeId: routeId, - }); - setFetcherError(key, routeId, error, { flushSync }); + function detectAndHandle405Error(m: AgnosticDataRouteMatch) { + if (!m.route.action && !m.route.lazy) { + let error = getInternalRouterError(405, { + method: submission.formMethod, + pathname: path, + routeId: routeId, + }); + setFetcherError(key, routeId, error, { flushSync }); + return true; + } + return false; + } + + if (!isFogOfWar && detectAndHandle405Error(match)) { return; } @@ -2011,7 +2206,6 @@ export function createRouter(init: RouterInit): Router { flushSync, }); - // Call the action for the fetcher let abortController = new AbortController(); let fetchRequest = createClientSideRequest( init.history, @@ -2019,6 +2213,39 @@ export function createRouter(init: RouterInit): Router { abortController.signal, submission ); + + if (isFogOfWar) { + let discoverResult = await discoverRoutes( + requestMatches, + path, + fetchRequest.signal + ); + + if (discoverResult.type === "aborted") { + return; + } else if (discoverResult.type === "error") { + let { error } = handleDiscoverRouteError(path, discoverResult); + setFetcherError(key, routeId, error, { flushSync }); + return; + } else if (!discoverResult.matches) { + setFetcherError( + key, + routeId, + getInternalRouterError(404, { pathname: path }), + { flushSync } + ); + return; + } else { + requestMatches = discoverResult.matches; + match = getTargetMatch(requestMatches, path); + + if (detectAndHandle405Error(match)) { + return; + } + } + } + + // Call the action for the fetcher fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; @@ -2247,6 +2474,7 @@ export function createRouter(init: RouterInit): Router { path: string, match: AgnosticDataRouteMatch, matches: AgnosticDataRouteMatch[], + isFogOfWar: boolean, flushSync: boolean, submission?: Submission ) { @@ -2260,13 +2488,41 @@ export function createRouter(init: RouterInit): Router { { flushSync } ); - // Call the loader for this fetcher route match let abortController = new AbortController(); let fetchRequest = createClientSideRequest( init.history, path, abortController.signal ); + + if (isFogOfWar) { + let discoverResult = await discoverRoutes( + matches, + path, + fetchRequest.signal + ); + + if (discoverResult.type === "aborted") { + return; + } else if (discoverResult.type === "error") { + let { error } = handleDiscoverRouteError(path, discoverResult); + setFetcherError(key, routeId, error, { flushSync }); + return; + } else if (!discoverResult.matches) { + setFetcherError( + key, + routeId, + getInternalRouterError(404, { pathname: path }), + { flushSync } + ); + return; + } else { + matches = discoverResult.matches; + match = getTargetMatch(matches, path); + } + } + + // Call the loader for this fetcher route match fetchControllers.set(key, abortController); let originatingLoadId = incrementingLoadId; @@ -2777,6 +3033,35 @@ export function createRouter(init: RouterInit): Router { } } + function handleNavigational404(pathname: string) { + let error = getInternalRouterError(404, { pathname }); + let routesToUse = inFlightDataRoutes || dataRoutes; + let { matches, route } = getShortCircuitMatches(routesToUse); + + // Cancel all pending deferred on 404s since we don't keep any routes + cancelActiveDeferreds(); + + return { notFoundMatches: matches, route, error }; + } + + function handleDiscoverRouteError( + pathname: string, + discoverResult: DiscoverRoutesErrorResult + ) { + let matches = discoverResult.partialMatches; + let route = matches[matches.length - 1].route; + let error = getInternalRouterError(400, { + type: "route-discovery", + routeId: route.id, + pathname, + message: + discoverResult.error != null && "message" in discoverResult.error + ? discoverResult.error + : String(discoverResult.error), + }); + return { notFoundMatches: matches, route, error }; + } + function cancelActiveDeferreds( predicate?: (routeId: string) => boolean ): string[] { @@ -2858,6 +3143,137 @@ export function createRouter(init: RouterInit): Router { return null; } + function checkFogOfWar( + matches: AgnosticDataRouteMatch[] | null, + routesToUse: AgnosticDataRouteObject[], + pathname: string + ): { active: boolean; matches: AgnosticDataRouteMatch[] | null } { + if (patchRoutesOnMissImpl) { + if (!matches) { + let fogMatches = matchRoutesImpl( + routesToUse, + pathname, + basename, + true + ); + + return { active: true, matches: fogMatches || [] }; + } else { + let leafRoute = matches[matches.length - 1].route; + if (leafRoute.path === "*") { + // If we matched a splat, it might only be because we haven't yet fetched + // the children that would match with a higher score, so let's fetch + // around and find out + let partialMatches = matchRoutesImpl( + routesToUse, + pathname, + basename, + true + ); + return { active: true, matches: partialMatches }; + } + } + } + + return { active: false, matches: null }; + } + + type DiscoverRoutesSuccessResult = { + type: "success"; + matches: AgnosticDataRouteMatch[] | null; + }; + type DiscoverRoutesErrorResult = { + type: "error"; + error: any; + partialMatches: AgnosticDataRouteMatch[]; + }; + type DiscoverRoutesAbortedResult = { type: "aborted" }; + type DiscoverRoutesResult = + | DiscoverRoutesSuccessResult + | DiscoverRoutesErrorResult + | DiscoverRoutesAbortedResult; + + async function discoverRoutes( + matches: AgnosticDataRouteMatch[], + pathname: string, + signal: AbortSignal + ): Promise { + let partialMatches: AgnosticDataRouteMatch[] | null = matches; + let route = + partialMatches.length > 0 + ? partialMatches[partialMatches.length - 1].route + : null; + while (true) { + try { + await loadLazyRouteChildren( + patchRoutesOnMissImpl!, + pathname, + partialMatches, + dataRoutes || inFlightDataRoutes, + manifest, + mapRouteProperties, + pendingPatchRoutes, + signal + ); + } catch (e) { + return { type: "error", error: e, partialMatches }; + } + + if (signal.aborted) { + return { type: "aborted" }; + } + + let routesToUse = inFlightDataRoutes || dataRoutes; + let newMatches = matchRoutes(routesToUse, pathname, basename); + let matchedSplat = false; + if (newMatches) { + let leafRoute = newMatches[newMatches.length - 1].route; + + if (leafRoute.index) { + // If we found an index route, we can stop + return { type: "success", matches: newMatches }; + } + + if (leafRoute.path && leafRoute.path.length > 0) { + if (leafRoute.path === "*") { + // If we found a splat route, we can't be sure there's not a + // higher-scoring route down some partial matches trail so we need + // to check that out + matchedSplat = true; + } else { + // If we found a non-splat route, we can stop + return { type: "success", matches: newMatches }; + } + } + } + + let newPartialMatches = matchRoutesImpl( + routesToUse, + pathname, + basename, + true + ); + + // If we are no longer partially matching anything, this was either a + // legit splat match above, or it's a 404. Also avoid loops if the + // second pass results in the same partial matches + if ( + !newPartialMatches || + partialMatches.map((m) => m.route.id).join("-") === + newPartialMatches.map((m) => m.route.id).join("-") + ) { + return { type: "success", matches: matchedSplat ? newMatches : null }; + } + + partialMatches = newPartialMatches; + route = partialMatches[partialMatches.length - 1].route; + if (route.path === "*") { + // The splat is still our most accurate partial, so run with it + return { type: "success", matches: partialMatches }; + } + } + } + function _internalSetRoutes(newRoutes: AgnosticDataRouteObject[]) { manifest = {}; inFlightDataRoutes = convertRoutesToDataRoutes( @@ -2899,6 +3315,15 @@ export function createRouter(init: RouterInit): Router { dispose, getBlocker, deleteBlocker, + patchRoutes(routeId, children) { + return patchRoutes( + routeId, + children, + dataRoutes || inFlightDataRoutes, + manifest, + mapRouteProperties + ); + }, _internalFetchControllers: fetchControllers, _internalActiveDeferreds: activeDeferreds, // TODO: Remove setRoutes, it's temporary to avoid dealing with @@ -4062,6 +4487,85 @@ function shouldRevalidateLoader( return arg.defaultShouldRevalidate; } +/** + * Idempotent utility to execute route.children() method to lazily load route + * definitions and update the routes/routeManifest + */ +async function loadLazyRouteChildren( + patchRoutesOnMissImpl: AgnosticPatchRoutesOnMissFunction, + path: string, + matches: AgnosticDataRouteMatch[], + routes: AgnosticDataRouteObject[], + manifest: RouteManifest, + mapRouteProperties: MapRoutePropertiesFunction, + pendingRouteChildren: Map>, + signal: AbortSignal +) { + let key = [path, ...matches.map((m) => m.route.id)].join("-"); + try { + let pending = pendingRouteChildren.get(key); + if (!pending) { + pending = patchRoutesOnMissImpl({ + path, + matches, + patch: (routeId, children) => { + if (!signal.aborted) { + patchRoutes( + routeId, + children, + routes, + manifest, + mapRouteProperties + ); + } + }, + }); + pendingRouteChildren.set(key, pending); + } + + if (pending && isPromise(pending)) { + await pending; + } + } finally { + pendingRouteChildren.delete(key); + } +} + +function patchRoutes( + routeId: string | null, + children: AgnosticRouteObject[], + routes: AgnosticDataRouteObject[], + manifest: RouteManifest, + mapRouteProperties: MapRoutePropertiesFunction +) { + if (routeId) { + let route = manifest[routeId]; + invariant( + route, + `No route found to patch children into: routeId = ${routeId}` + ); + let dataChildren = convertRoutesToDataRoutes( + children, + mapRouteProperties, + [routeId, "patch", String(route.children?.length || "0")], + manifest + ); + if (route.children) { + route.children.push(...dataChildren); + } else { + route.children = dataChildren; + } + } else { + let dataChildren = convertRoutesToDataRoutes( + children, + mapRouteProperties, + ["patch", String(routes.length || "0")], + manifest + ); + routes.push(...dataChildren); + } +} + /** * Execute route.lazy() methods to lazily load route modules (loader, action, * shouldRevalidate) and update the routeManifest in place which shares objects @@ -4797,11 +5301,13 @@ function getInternalRouterError( routeId, method, type, + message, }: { pathname?: string; routeId?: string; method?: string; - type?: "defer-action" | "invalid-body"; + type?: "defer-action" | "invalid-body" | "route-discovery"; + message?: string; } = {} ) { let statusText = "Unknown Server Error"; @@ -4809,7 +5315,11 @@ function getInternalRouterError( if (status === 400) { statusText = "Bad Request"; - if (method && pathname && routeId) { + if (type === "route-discovery") { + errorMessage = + `Unable to match URL "${pathname}" - the \`children()\` function for ` + + `route \`${routeId}\` threw the following error:\n${message}`; + } else if (method && pathname && routeId) { errorMessage = `You made a ${method} request to "${pathname}" but ` + `did not provide a \`loader\` for route "${routeId}", ` + @@ -4883,6 +5393,10 @@ function isHashChangeOnly(a: Location, b: Location): boolean { return false; } +function isPromise(val: unknown): val is Promise { + return typeof val === "object" && val != null && "then" in val; +} + function isHandlerResult(result: unknown): result is HandlerResult { return ( result != null && @@ -5243,5 +5757,4 @@ function persistAppliedTransitions( } } } - //#endregion diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 561421e777..2c5e30eabc 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -255,6 +255,16 @@ export interface DataStrategyFunction { (args: DataStrategyFunctionArgs): Promise; } +export interface AgnosticPatchRoutesOnMissFunction< + M extends AgnosticRouteMatch = AgnosticRouteMatch +> { + (opts: { + path: string; + matches: M[]; + patch: (routeId: string | null, children: AgnosticRouteObject[]) => void; + }): void | Promise; +} + /** * Function provided by the framework-aware layers to set any framework-specific * properties from framework-agnostic properties @@ -444,11 +454,11 @@ function isIndexRoute( export function convertRoutesToDataRoutes( routes: AgnosticRouteObject[], mapRouteProperties: MapRoutePropertiesFunction, - parentPath: number[] = [], + parentPath: string[] = [], manifest: RouteManifest = {} ): AgnosticDataRouteObject[] { return routes.map((route, index) => { - let treePath = [...parentPath, index]; + let treePath = [...parentPath, String(index)]; let id = typeof route.id === "string" ? route.id : treePath.join("-"); invariant( route.index !== true || !route.children, @@ -502,6 +512,17 @@ export function matchRoutes< routes: RouteObjectType[], locationArg: Partial | string, basename = "/" +): AgnosticRouteMatch[] | null { + return matchRoutesImpl(routes, locationArg, basename, false); +} + +export function matchRoutesImpl< + RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject +>( + routes: RouteObjectType[], + locationArg: Partial | string, + basename: string, + allowPartial: boolean ): AgnosticRouteMatch[] | null { let location = typeof locationArg === "string" ? parsePath(locationArg) : locationArg; @@ -524,7 +545,11 @@ export function matchRoutes< // should be a safe operation. This avoids needing matchRoutes to be // history-aware. let decoded = decodePath(pathname); - matches = matchRouteBranch(branches[i], decoded); + matches = matchRouteBranch( + branches[i], + decoded, + allowPartial + ); } return matches; @@ -615,7 +640,6 @@ function flattenRoutes< `Index routes must not have child routes. Please remove ` + `all child routes from route path "${path}".` ); - flattenRoutes(route.children, branches, routesMeta, path); } @@ -768,7 +792,8 @@ function matchRouteBranch< RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject >( branch: RouteBranch, - pathname: string + pathname: string, + allowPartial = false ): AgnosticRouteMatch[] | null { let { routesMeta } = branch; @@ -787,11 +812,29 @@ function matchRouteBranch< remainingPathname ); - if (!match) return null; + let route = meta.route; - Object.assign(matchedParams, match.params); + if ( + !match && + end && + allowPartial && + !routesMeta[routesMeta.length - 1].route.index + ) { + match = matchPath( + { + path: meta.relativePath, + caseSensitive: meta.caseSensitive, + end: false, + }, + remainingPathname + ); + } - let route = meta.route; + if (!match) { + return null; + } + + Object.assign(matchedParams, match.params); matches.push({ // TODO: Can this as be avoided?