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?