diff --git a/.changeset/spicy-lamps-look.md b/.changeset/spicy-lamps-look.md new file mode 100644 index 0000000000..ff534007b4 --- /dev/null +++ b/.changeset/spicy-lamps-look.md @@ -0,0 +1,35 @@ +--- +"react-router": minor +--- + +Add granular object-based API for `route.lazy` to support lazy loading of individual route properties, for example: + +```ts +createBrowserRouter([ + { + path: "/show/:showId", + lazy: { + loader: async () => (await import("./show.loader.js")).loader, + action: async () => (await import("./show.action.js")).action, + Component: async () => (await import("./show.component.js")).Component, + }, + }, +]); +``` + +**Breaking change for `route.unstable_lazyMiddleware` consumers** + +The `route.unstable_lazyMiddleware` property is no longer supported. If you want to lazily load middleware, you must use the new object-based `route.lazy` API with `route.lazy.unstable_middleware`, for example: + +```ts +createBrowserRouter([ + { + path: "/show/:showId", + lazy: { + unstable_middleware: async () => + (await import("./show.middleware.js")).middleware, + // etc. + }, + }, +]); +``` diff --git a/docs/start/data/custom.md b/docs/start/data/custom.md index f115dca0fa..01a17a5d59 100644 --- a/docs/start/data/custom.md +++ b/docs/start/data/custom.md @@ -64,13 +64,13 @@ Routes can take most of their definition lazily with the `lazy` property. createBrowserRouter([ { path: "/show/:showId", - lazy: () => { - let [loader, action, Component] = await Promise.all([ - import("./show.action.js"), - import("./show.loader.js"), - import("./show.component.js"), - ]); - return { loader, action, Component }; + lazy: { + loader: async () => + (await import("./show.loader.js")).loader, + action: async () => + (await import("./show.action.js")).action, + Component: async () => + (await import("./show.component.js")).Component, }, }, ]); diff --git a/packages/react-router/__tests__/router/context-middleware-test.ts b/packages/react-router/__tests__/router/context-middleware-test.ts index 1fcadb742f..c0ee866013 100644 --- a/packages/react-router/__tests__/router/context-middleware-test.ts +++ b/packages/react-router/__tests__/router/context-middleware-test.ts @@ -562,15 +562,17 @@ describe("context/middleware", () => { { id: "parent", path: "/parent", - unstable_lazyMiddleware: async () => [ - async ({ context }, next) => { - await next(); - // Grab a snapshot at the end of the upwards middleware chain - snapshot = context.get(orderContext); - }, - getOrderMiddleware(orderContext, "a"), - getOrderMiddleware(orderContext, "b"), - ], + lazy: { + unstable_middleware: async () => [ + async ({ context }, next) => { + await next(); + // Grab a snapshot at the end of the upwards middleware chain + snapshot = context.get(orderContext); + }, + getOrderMiddleware(orderContext, "a"), + getOrderMiddleware(orderContext, "b"), + ], + }, loader({ context }) { context.get(orderContext).push("parent loader"); }, @@ -578,10 +580,12 @@ describe("context/middleware", () => { { id: "child", path: "child", - unstable_lazyMiddleware: async () => [ - getOrderMiddleware(orderContext, "c"), - getOrderMiddleware(orderContext, "d"), - ], + lazy: { + unstable_middleware: async () => [ + getOrderMiddleware(orderContext, "c"), + getOrderMiddleware(orderContext, "d"), + ], + }, loader({ context }) { context.get(orderContext).push("child loader"); }, @@ -634,10 +638,12 @@ describe("context/middleware", () => { { id: "child", path: "child", - unstable_lazyMiddleware: async () => [ - getOrderMiddleware(orderContext, "c"), - getOrderMiddleware(orderContext, "d"), - ], + lazy: { + unstable_middleware: async () => [ + getOrderMiddleware(orderContext, "c"), + getOrderMiddleware(orderContext, "d"), + ], + }, loader({ context }) { context.get(orderContext).push("child loader"); }, @@ -663,7 +669,7 @@ describe("context/middleware", () => { ]); }); - it("ignores middleware returned from route.lazy", async () => { + it("ignores middleware returned from route.lazy function", async () => { let snapshot; let consoleWarn = jest @@ -679,15 +685,17 @@ describe("context/middleware", () => { { id: "parent", path: "/parent", - unstable_lazyMiddleware: async () => [ - async ({ context }, next) => { - await next(); - // Grab a snapshot at the end of the upwards middleware chain - snapshot = context.get(orderContext); - }, - getOrderMiddleware(orderContext, "a"), - getOrderMiddleware(orderContext, "b"), - ], + lazy: { + unstable_middleware: async () => [ + async ({ context }, next) => { + await next(); + // Grab a snapshot at the end of the upwards middleware chain + snapshot = context.get(orderContext); + }, + getOrderMiddleware(orderContext, "a"), + getOrderMiddleware(orderContext, "b"), + ], + }, loader({ context }) { context.get(orderContext).push("parent loader"); }, @@ -726,70 +734,6 @@ describe("context/middleware", () => { "Route property unstable_middleware is not a supported property to be returned from a lazy route function. This property will be ignored." ); }); - - it("ignores lazy middleware returned from route.lazy", async () => { - let snapshot; - - let consoleWarn = jest - .spyOn(console, "warn") - .mockImplementation(() => {}); - - router = createRouter({ - history: createMemoryHistory(), - routes: [ - { - path: "/", - }, - { - id: "parent", - path: "/parent", - unstable_lazyMiddleware: async () => [ - async ({ context }, next) => { - await next(); - // Grab a snapshot at the end of the upwards middleware chain - snapshot = context.get(orderContext); - }, - getOrderMiddleware(orderContext, "a"), - getOrderMiddleware(orderContext, "b"), - ], - loader({ context }) { - context.get(orderContext).push("parent loader"); - }, - children: [ - { - id: "child", - path: "child", - // @ts-expect-error - lazy: async () => ({ - unstable_lazyMiddleware: async () => [ - getOrderMiddleware(orderContext, "c"), - getOrderMiddleware(orderContext, "d"), - ], - }), - loader({ context }) { - context.get(orderContext).push("child loader"); - }, - }, - ], - }, - ], - }); - - await router.navigate("/parent/child"); - - expect(snapshot).toEqual([ - "a middleware - before next()", - "b middleware - before next()", - "parent loader", - "child loader", - "b middleware - after next()", - "a middleware - after next()", - ]); - - expect(consoleWarn).toHaveBeenCalledWith( - "Route property unstable_lazyMiddleware is not a supported property to be returned from a lazy route function. This property will be ignored." - ); - }); }); describe("throwing", () => { @@ -1581,18 +1525,20 @@ describe("context/middleware", () => { { id: "parent", path: "/parent", - unstable_lazyMiddleware: async () => [ - async (_, next) => { - let res = (await next()) as Response; - res.headers.set("parent1", "yes"); - return res; - }, - async (_, next) => { - let res = (await next()) as Response; - res.headers.set("parent2", "yes"); - return res; - }, - ], + lazy: { + unstable_middleware: async () => [ + async (_, next) => { + let res = (await next()) as Response; + res.headers.set("parent1", "yes"); + return res; + }, + async (_, next) => { + let res = (await next()) as Response; + res.headers.set("parent2", "yes"); + return res; + }, + ], + }, loader() { return "PARENT"; }, @@ -1600,18 +1546,20 @@ describe("context/middleware", () => { { id: "child", path: "child", - unstable_lazyMiddleware: async () => [ - async (_, next) => { - let res = (await next()) as Response; - res.headers.set("child1", "yes"); - return res; - }, - async (_, next) => { - let res = (await next()) as Response; - res.headers.set("child2", "yes"); - return res; - }, - ], + lazy: { + unstable_middleware: async () => [ + async (_, next) => { + let res = (await next()) as Response; + res.headers.set("child1", "yes"); + return res; + }, + async (_, next) => { + let res = (await next()) as Response; + res.headers.set("child2", "yes"); + return res; + }, + ], + }, loader() { return "CHILD"; }, @@ -2507,18 +2455,20 @@ describe("context/middleware", () => { { id: "parent", path: "/parent", - unstable_lazyMiddleware: async () => [ - async ({ context }, next) => { - let res = (await next()) as Response; - res.headers.set("parent1", "yes"); - return res; - }, - async ({ context }, next) => { - let res = (await next()) as Response; - res.headers.set("parent2", "yes"); - return res; - }, - ], + lazy: { + unstable_middleware: async () => [ + async ({ context }, next) => { + let res = (await next()) as Response; + res.headers.set("parent1", "yes"); + return res; + }, + async ({ context }, next) => { + let res = (await next()) as Response; + res.headers.set("parent2", "yes"); + return res; + }, + ], + }, loader() { return new Response("PARENT"); }, @@ -2526,18 +2476,20 @@ describe("context/middleware", () => { { id: "child", path: "child", - unstable_lazyMiddleware: async () => [ - async ({ context }, next) => { - let res = (await next()) as Response; - res.headers.set("child1", "yes"); - return res; - }, - async ({ context }, next) => { - let res = (await next()) as Response; - res.headers.set("child2", "yes"); - return res; - }, - ], + lazy: { + unstable_middleware: async () => [ + async ({ context }, next) => { + let res = (await next()) as Response; + res.headers.set("child1", "yes"); + return res; + }, + async ({ context }, next) => { + let res = (await next()) as Response; + res.headers.set("child2", "yes"); + return res; + }, + ], + }, loader({ context }) { return new Response("CHILD"); }, diff --git a/packages/react-router/__tests__/router/lazy-test.ts b/packages/react-router/__tests__/router/lazy-test.ts index b6ba94d08f..c7f39bdefe 100644 --- a/packages/react-router/__tests__/router/lazy-test.ts +++ b/packages/react-router/__tests__/router/lazy-test.ts @@ -1,7 +1,10 @@ import { createMemoryHistory } from "../../lib/router/history"; import { createRouter, createStaticHandler } from "../../lib/router/router"; -import type { TestRouteObject } from "./utils/data-router-setup"; +import type { + TestNonIndexRouteObject, + TestRouteObject, +} from "./utils/data-router-setup"; import { cleanup, createDeferred, @@ -29,33 +32,39 @@ describe("lazily loaded route modules", () => { console.warn.mockReset(); }); - const createBasicLazyRoutes = (): { + const createBasicLazyRoutes = ( + lazy: TestNonIndexRouteObject["lazy"] + ): TestRouteObject[] => { + return [ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "/lazy", + lazy, + }, + ], + }, + ]; + }; + + const createBasicLazyFunctionRoutes = (): { routes: TestRouteObject[]; lazyStub: jest.Mock; lazyDeferred: ReturnType; } => { let { lazyStub, lazyDeferred } = createLazyStub(); return { - routes: [ - { - id: "root", - path: "/", - children: [ - { - id: "lazy", - path: "/lazy", - lazy: lazyStub, - }, - ], - }, - ], + routes: createBasicLazyRoutes(lazyStub), lazyStub, lazyDeferred, }; }; describe("initialization", () => { - it("fetches lazy route modules on router initialization", async () => { + it("fetches lazy route functions on router initialization", async () => { let lazyDeferred = createDeferred(); let router = createRouter({ routes: [ @@ -80,7 +89,186 @@ describe("lazily loaded route modules", () => { expect(router.state.matches[0].route).toMatchObject(route); }); - it("fetches lazy route modules and executes loaders on router initialization", async () => { + it("resolves lazy route properties on router initialization", async () => { + let lazyLoaderDeferred = createDeferred(); + let lazyActionDeferred = createDeferred(); + let router = createRouter({ + routes: [ + { + path: "/lazy", + lazy: { + loader: () => lazyLoaderDeferred.promise, + action: () => lazyActionDeferred.promise, + }, + }, + ], + history: createMemoryHistory({ initialEntries: ["/lazy"] }), + }); + + expect(router.state.initialized).toBe(false); + + router.initialize(); + + let loader = () => null; + await lazyLoaderDeferred.resolve(loader); + + let action = () => null; + await lazyActionDeferred.resolve(action); + + expect(router.state.location.pathname).toBe("/lazy"); + expect(router.state.navigation.state).toBe("idle"); + expect(router.state.initialized).toBe(true); + expect(router.state.matches[0].route).toMatchObject({ + loader, + action, + }); + }); + + it("ignores falsy lazy route properties on router initialization", async () => { + let lazyLoaderDeferred = createDeferred(); + let lazyActionDeferred = createDeferred(); + let router = createRouter({ + routes: [ + { + path: "/lazy", + lazy: { + loader: () => lazyLoaderDeferred.promise, + action: () => lazyActionDeferred.promise, + }, + }, + ], + history: createMemoryHistory({ initialEntries: ["/lazy"] }), + }); + + expect(router.state.initialized).toBe(false); + + router.initialize(); + + await lazyLoaderDeferred.resolve(null); + await lazyActionDeferred.resolve(undefined); + + expect(router.state.location.pathname).toBe("/lazy"); + expect(router.state.navigation.state).toBe("idle"); + expect(router.state.initialized).toBe(true); + expect(router.state.loaderData).toEqual({}); + expect(router.state.matches[0].route.loader).toBeUndefined(); + expect(router.state.matches[0].route.action).toBeUndefined(); + }); + + it("ignores and warns on unsupported lazy route function properties on router initialization", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let lazyLoaderDeferred = createDeferred(); + let router = createRouter({ + routes: [ + { + path: "/lazy", + // @ts-expect-error + lazy: async () => { + return { + loader: () => lazyLoaderDeferred.promise, + lazy: async () => { + throw new Error("SHOULD NOT BE CALLED"); + }, + caseSensitive: async () => true, + path: async () => "/lazy/path", + id: async () => "lazy", + index: async () => true, + children: async () => [], + }; + }, + }, + ], + history: createMemoryHistory({ initialEntries: ["/lazy"] }), + }); + + expect(router.state.initialized).toBe(false); + + router.initialize(); + + let LOADER_DATA = 123; + await lazyLoaderDeferred.resolve(LOADER_DATA); + + expect(router.state.location.pathname).toBe("/lazy"); + expect(router.state.navigation.state).toBe("idle"); + expect(router.state.initialized).toBe(true); + expect(router.state.loaderData).toEqual({ + "0": LOADER_DATA, + }); + + expect(consoleWarn.mock.calls.map((call) => call[0]).sort()) + .toMatchInlineSnapshot(` + [ + "Route property caseSensitive is not a supported property to be returned from a lazy route function. This property will be ignored.", + "Route property children is not a supported property to be returned from a lazy route function. This property will be ignored.", + "Route property id is not a supported property to be returned from a lazy route function. This property will be ignored.", + "Route property index is not a supported property to be returned from a lazy route function. This property will be ignored.", + "Route property lazy is not a supported property to be returned from a lazy route function. This property will be ignored.", + "Route property path is not a supported property to be returned from a lazy route function. This property will be ignored.", + ] + `); + consoleWarn.mockReset(); + }); + + it("ignores and warns on unsupported lazy route properties on router initialization", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let lazyLoaderDeferred = createDeferred(); + let router = createRouter({ + routes: [ + { + path: "/lazy", + lazy: { + loader: () => lazyLoaderDeferred.promise, + // @ts-expect-error + lazy: async () => { + throw new Error("SHOULD NOT BE CALLED"); + }, + // @ts-expect-error + caseSensitive: async () => true, + // @ts-expect-error + path: async () => "/lazy/path", + // @ts-expect-error + id: async () => "lazy", + // @ts-expect-error + index: async () => true, + // @ts-expect-error + children: async () => [], + }, + }, + ], + history: createMemoryHistory({ initialEntries: ["/lazy"] }), + }); + + expect(router.state.initialized).toBe(false); + + router.initialize(); + + let LOADER_DATA = 123; + let loader = () => LOADER_DATA; + await lazyLoaderDeferred.resolve(loader); + + expect(router.state.location.pathname).toBe("/lazy"); + expect(router.state.navigation.state).toBe("idle"); + expect(router.state.initialized).toBe(true); + expect(router.state.loaderData).toEqual({ + "0": LOADER_DATA, + }); + expect(router.state.matches[0].route.loader).toBe(loader); + + expect(consoleWarn.mock.calls.map((call) => call[0]).sort()) + .toMatchInlineSnapshot(` + [ + "Route property caseSensitive is not a supported lazy route property. This property will be ignored.", + "Route property children is not a supported lazy route property. This property will be ignored.", + "Route property id is not a supported lazy route property. This property will be ignored.", + "Route property index is not a supported lazy route property. This property will be ignored.", + "Route property lazy is not a supported lazy route property. This property will be ignored.", + "Route property path is not a supported lazy route property. This property will be ignored.", + ] + `); + consoleWarn.mockReset(); + }); + + it("fetches lazy route functions and executes loaders on router initialization", async () => { let lazyDeferred = createDeferred(); let router = createRouter({ routes: [ @@ -113,11 +301,44 @@ describe("lazily loaded route modules", () => { }); expect(router.state.matches[0].route).toMatchObject(route); }); + + it("resolves lazy route properties and executes loaders on router initialization", async () => { + let lazyLoaderDeferred = createDeferred(); + let router = createRouter({ + routes: [ + { + path: "/lazy", + lazy: { + loader: () => lazyLoaderDeferred.promise, + }, + }, + ], + history: createMemoryHistory({ initialEntries: ["/lazy"] }), + }); + + expect(router.state.initialized).toBe(false); + + router.initialize(); + + let loaderDeferred = createDeferred(); + let loader = () => loaderDeferred.promise; + await lazyLoaderDeferred.resolve(loader); + expect(router.state.initialized).toBe(false); + + await loaderDeferred.resolve("LOADER"); + expect(router.state.location.pathname).toBe("/lazy"); + expect(router.state.navigation.state).toBe("idle"); + expect(router.state.initialized).toBe(true); + expect(router.state.loaderData).toEqual({ + "0": "LOADER", + }); + expect(router.state.matches[0].route).toMatchObject({ loader }); + }); }); describe("happy path", () => { - it("fetches lazy route modules on loading navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("fetches lazy route functions on loading navigation", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -144,8 +365,58 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("fetches lazy route modules on submission navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("resolves lazy route properties on loading navigation", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + lazyDeferred.resolve(() => loaderDeferred.promise); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await loaderDeferred.resolve("LAZY LOADER"); + + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.loaderData).toEqual({ + lazy: "LAZY LOADER", + }); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("ignores falsy lazy route properties on loading navigation", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await lazyDeferred.resolve(null); + expect(t.router.state.matches[0].route.loader).toBeUndefined(); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.loaderData).toEqual({}); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("fetches lazy route functions on submission navigation", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -187,8 +458,93 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("fetches lazy route modules on fetcher.load", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("resolves lazy route properties on submission navigation", async () => { + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyLoaderStub, + action: lazyActionStub, + }); + let t = setup({ routes }); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyActionStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + let loaderDeferred = createDeferred(); + lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + lazyActionDeferred.resolve(() => actionDeferred.promise); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + await actionDeferred.resolve("LAZY ACTION"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(t.router.state.actionData).toEqual({ + lazy: "LAZY ACTION", + }); + expect(t.router.state.loaderData).toEqual({}); + + await loaderDeferred.resolve("LAZY LOADER"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual({ + lazy: "LAZY ACTION", + }); + expect(t.router.state.loaderData).toEqual({ + lazy: "LAZY LOADER", + }); + + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + }); + + it("ignores falsy lazy route properties on submission navigation", async () => { + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyLoaderStub, + action: lazyActionStub, + }); + let t = setup({ routes }); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyActionStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + + await lazyLoaderDeferred.resolve(undefined); + await lazyActionDeferred.resolve(null); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.loaderData).toEqual({}); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(t.router.state.matches[0].route.loader).toBeUndefined(); + expect(t.router.state.matches[0].route.action).toBeUndefined(); + }); + + it("fetches lazy route functions on fetcher.load", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -210,22 +566,46 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("fetches lazy route modules on fetcher.submit", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("resolves lazy route properties on fetcher.load", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); let key = "key"; - await t.fetch("/lazy", key, { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); expect(lazyStub).toHaveBeenCalledTimes(1); - let actionDeferred = createDeferred(); - lazyDeferred.resolve({ - action: () => actionDeferred.promise, + let loaderDeferred = createDeferred(); + lazyDeferred.resolve(() => loaderDeferred.promise); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await loaderDeferred.resolve("LAZY LOADER"); + expect(t.fetchers[key].state).toBe("idle"); + expect(t.fetchers[key].data).toBe("LAZY LOADER"); + + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("fetches lazy route functions on fetcher.submit", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + lazyDeferred.resolve({ + action: () => actionDeferred.promise, }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); @@ -236,7 +616,43 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("fetches lazy route modules on staticHandler.query()", async () => { + it("resolves lazy route properties on fetcher.submit", async () => { + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyLoaderStub, + action: lazyActionStub, + }); + let t = setup({ routes }); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyActionStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + let loaderDeferred = createDeferred(); + lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + lazyActionDeferred.resolve(() => actionDeferred.promise); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + + await actionDeferred.resolve("LAZY ACTION"); + expect(t.fetchers[key]?.state).toBe("idle"); + expect(t.fetchers[key]?.data).toBe("LAZY ACTION"); + + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + }); + + it("fetches lazy route functions on staticHandler.query()", async () => { let { query } = createStaticHandler([ { id: "lazy", @@ -260,7 +676,29 @@ describe("lazily loaded route modules", () => { expect(context.loaderData).toEqual({ lazy: { value: "LAZY LOADER" } }); }); - it("fetches lazy route modules on staticHandler.queryRoute()", async () => { + it("resolves lazy route properties on staticHandler.query()", async () => { + let { query } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + await tick(); + return () => Response.json({ value: "LAZY LOADER" }); + }, + }, + }, + ]); + + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ lazy: { value: "LAZY LOADER" } }); + }); + + it("fetches lazy route functions on staticHandler.queryRoute()", async () => { let { queryRoute } = createStaticHandler([ { id: "lazy", @@ -280,10 +718,29 @@ describe("lazily loaded route modules", () => { let data = await response.json(); expect(data).toEqual({ value: "LAZY LOADER" }); }); + + it("resolves lazy route properties on staticHandler.queryRoute()", async () => { + let { queryRoute } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + await tick(); + return () => Response.json({ value: "LAZY LOADER" }); + }, + }, + }, + ]); + + let response = await queryRoute(createRequest("/lazy")); + let data = await response.json(); + expect(data).toEqual({ value: "LAZY LOADER" }); + }); }); describe("statically defined fields", () => { - it("prefers statically defined loader over lazily defined loader", async () => { + it("prefers statically defined loader over lazily defined loader via lazy function", async () => { let consoleWarn = jest.spyOn(console, "warn"); let { lazyStub, lazyDeferred } = createLazyStub(); let t = setup({ @@ -332,7 +789,103 @@ describe("lazily loaded route modules", () => { consoleWarn.mockReset(); }); - it("prefers statically defined action over lazily loaded action", async () => { + it("prefers statically defined loader over lazily defined loader via lazy property", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let { lazyStub, lazyDeferred } = createLazyStub(); + let t = setup({ + routes: [ + { + id: "lazy", + path: "/lazy", + loader: true, + lazy: { + loader: lazyStub, + }, + }, + ], + }); + + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + // Execute in parallel + expect(A.loaders.lazy.stub).toHaveBeenCalled(); + expect(lazyStub).toHaveBeenCalledTimes(0); + + let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); + lazyDeferred.resolve(lazyLoaderStub); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + + await A.loaders.lazy.resolve("STATIC LOADER"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.loaderData).toEqual({ + lazy: "STATIC LOADER", + }); + + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.loader).toEqual(expect.any(Function)); + expect(lazyRoute.loader).not.toBe(lazyLoaderStub); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyStub).toHaveBeenCalledTimes(0); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "loader" defined. The lazy property will be ignored."` + ); + consoleWarn.mockReset(); + }); + + it("prefers statically defined loader over lazily defined falsy loader via lazy property", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let { lazyStub, lazyDeferred } = createLazyStub(); + let t = setup({ + routes: [ + { + id: "lazy", + path: "/lazy", + loader: true, + lazy: { + loader: lazyStub, + }, + }, + ], + }); + + let A = await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + // Execute in parallel + expect(A.loaders.lazy.stub).toHaveBeenCalled(); + expect(lazyStub).toHaveBeenCalledTimes(0); + + lazyDeferred.resolve(null); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + + await A.loaders.lazy.resolve("STATIC LOADER"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.loaderData).toEqual({ + lazy: "STATIC LOADER", + }); + + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.loader).toEqual(expect.any(Function)); + expect(lazyRoute.loader).toBeInstanceOf(Function); + expect(lazyStub).toHaveBeenCalledTimes(0); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "loader" defined. The lazy property will be ignored."` + ); + consoleWarn.mockReset(); + }); + + it("prefers statically defined action over lazily loaded action via lazy function", async () => { let consoleWarn = jest.spyOn(console, "warn"); let { lazyStub, lazyDeferred } = createLazyStub(); let t = setup({ @@ -397,7 +950,78 @@ describe("lazily loaded route modules", () => { consoleWarn.mockReset(); }); - it("prefers statically defined action/loader over lazily defined action/loader", async () => { + it("prefers statically defined action over lazily loaded action via lazy property", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let t = setup({ + routes: [ + { + id: "lazy", + path: "/lazy", + action: true, + lazy: { + action: lazyActionStub, + loader: lazyLoaderStub, + }, + }, + ], + }); + + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + // Execute in parallel + expect(A.actions.lazy.stub).toHaveBeenCalled(); + expect(lazyActionStub).toHaveBeenCalledTimes(0); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + + let actionStub = jest.fn(() => "LAZY ACTION"); + let loaderDeferred = createDeferred(); + lazyActionDeferred.resolve(actionStub); + lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + await A.actions.lazy.resolve("STATIC ACTION"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(t.router.state.actionData).toEqual({ + lazy: "STATIC ACTION", + }); + expect(t.router.state.loaderData).toEqual({}); + + await loaderDeferred.resolve("LAZY LOADER"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual({ + lazy: "STATIC ACTION", + }); + expect(t.router.state.loaderData).toEqual({ + lazy: "LAZY LOADER", + }); + + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.action).toEqual(expect.any(Function)); + expect(lazyRoute.action).not.toBe(actionStub); + expect(actionStub).not.toHaveBeenCalled(); + expect(lazyActionStub).toHaveBeenCalledTimes(0); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "action" defined. The lazy property will be ignored."` + ); + consoleWarn.mockReset(); + }); + + it("prefers statically defined action/loader over lazily defined action/loader via lazy function", async () => { let consoleWarn = jest.spyOn(console, "warn"); let { lazyStub, lazyDeferred } = createLazyStub(); let t = setup({ @@ -467,30 +1091,106 @@ describe("lazily loaded route modules", () => { consoleWarn.mockReset(); }); - it("prefers statically defined loader over lazily defined loader (staticHandler.query)", async () => { + it("prefers statically defined action/loader over lazily defined action/loader via lazy property", async () => { let consoleWarn = jest.spyOn(console, "warn"); - let lazyLoaderStub = jest.fn(async () => { - await tick(); - return Response.json({ value: "LAZY LOADER" }); + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let t = setup({ + routes: [ + { + id: "lazy", + path: "/lazy", + action: true, + loader: true, + lazy: { + action: lazyActionStub, + loader: lazyLoaderStub, + }, + }, + ], }); - let lazyStub = jest.fn(async () => { - await tick(); - return { - loader: lazyLoaderStub, - }; + + let A = await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(0); - let { query } = createStaticHandler([ - { - id: "lazy", - path: "/lazy", - loader: async () => { - await tick(); - return Response.json({ value: "STATIC LOADER" }); - }, - lazy: lazyStub, - }, - ]); + expect(lazyActionStub).toHaveBeenCalledTimes(0); + let actionStub = jest.fn(() => "LAZY ACTION"); + let loaderStub = jest.fn(() => "LAZY LOADER"); + lazyActionDeferred.resolve(actionStub); + lazyLoaderDeferred.resolve(loaderStub); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + await A.actions.lazy.resolve("STATIC ACTION"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(t.router.state.actionData).toEqual({ + lazy: "STATIC ACTION", + }); + expect(t.router.state.loaderData).toEqual({}); + + await A.loaders.lazy.resolve("STATIC LOADER"); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual({ + lazy: "STATIC ACTION", + }); + expect(t.router.state.loaderData).toEqual({ + lazy: "STATIC LOADER", + }); + + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.action).toEqual(expect.any(Function)); + expect(lazyRoute.loader).toEqual(expect.any(Function)); + expect(lazyRoute.action).not.toBe(actionStub); + expect(lazyRoute.loader).not.toBe(loaderStub); + expect(actionStub).not.toHaveBeenCalled(); + expect(loaderStub).not.toHaveBeenCalled(); + expect(lazyActionStub).toHaveBeenCalledTimes(0); + expect(lazyLoaderStub).toHaveBeenCalledTimes(0); + + expect(consoleWarn).toHaveBeenCalledTimes(2); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "action" defined. The lazy property will be ignored."` + ); + expect(consoleWarn.mock.calls[1][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "loader" defined. The lazy property will be ignored."` + ); + consoleWarn.mockReset(); + }); + + it("prefers statically defined loader over lazily defined loader via lazy function (staticHandler.query)", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let lazyLoaderStub = jest.fn(async () => { + await tick(); + return Response.json({ value: "LAZY LOADER" }); + }); + let lazyStub = jest.fn(async () => { + await tick(); + return { + loader: lazyLoaderStub, + }; + }); + + let { query } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + loader: async () => { + await tick(); + return Response.json({ value: "STATIC LOADER" }); + }, + lazy: lazyStub, + }, + ]); let context = await query(createRequest("/lazy")); invariant( @@ -510,7 +1210,50 @@ describe("lazily loaded route modules", () => { consoleWarn.mockReset(); }); - it("prefers statically defined loader over lazily defined loader (staticHandler.queryRoute)", async () => { + it("prefers statically defined loader over lazily defined loader via lazy property (staticHandler.query)", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let loaderStub = jest.fn(async () => { + await tick(); + return Response.json({ value: "LAZY LOADER" }); + }); + let lazyStub = jest.fn(async () => { + await tick(); + return loaderStub; + }); + + let { query } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + loader: async () => { + await tick(); + return Response.json({ value: "STATIC LOADER" }); + }, + lazy: { + loader: lazyStub, + }, + }, + ]); + + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ + lazy: { value: "STATIC LOADER" }, + }); + expect(lazyStub).not.toHaveBeenCalled(); + expect(loaderStub).not.toHaveBeenCalled(); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "loader" defined. The lazy property will be ignored."` + ); + consoleWarn.mockReset(); + }); + + it("prefers statically defined loader over lazily defined loader via lazy function (staticHandler.queryRoute)", async () => { let consoleWarn = jest.spyOn(console, "warn"); let lazyLoaderStub = jest.fn(async () => { await tick(); @@ -551,7 +1294,50 @@ describe("lazily loaded route modules", () => { consoleWarn.mockReset(); }); - it("handles errors thrown from static loaders before lazy has completed", async () => { + it("prefers statically defined loader over lazily defined loader via lazy property (staticHandler.queryRoute)", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let loaderStub = jest.fn(async () => { + await tick(); + return Response.json({ value: "LAZY LOADER" }); + }); + let lazyLoaderStub = jest.fn(async () => { + await tick(); + return loaderStub; + }); + + let { query } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + loader: async () => { + await tick(); + return Response.json({ value: "STATIC LOADER" }); + }, + lazy: { + loader: lazyLoaderStub, + }, + }, + ]); + + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ + lazy: { value: "STATIC LOADER" }, + }); + expect(loaderStub).not.toHaveBeenCalled(); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn.mock.calls[0][0]).toMatchInlineSnapshot( + `"Route "lazy" has a static property "loader" defined. The lazy property will be ignored."` + ); + consoleWarn.mockReset(); + }); + + it("handles errors thrown from static loaders before lazy function has completed", async () => { let consoleWarn = jest.spyOn(console, "warn"); let { lazyStub, lazyDeferred } = createLazyStub(); let t = setup({ @@ -591,11 +1377,93 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); consoleWarn.mockReset(); }); + + it("handles errors thrown from static loaders before lazy property has resolved", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let { lazyStub, lazyDeferred } = createLazyStub(); + let t = setup({ + routes: [ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "lazy", + loader: true, + lazy: { + hasErrorBoundary: lazyStub, + }, + }, + ], + }, + ], + }); + expect(lazyStub).not.toHaveBeenCalled(); + + let A = await t.navigate("/lazy"); + + await A.loaders.lazy.reject("STATIC LOADER ERROR"); + expect(t.router.state.navigation.state).toBe("loading"); + + // We shouldn't bubble the loader error until after this resolves + // so we know if it has a boundary or not + await lazyDeferred.resolve(true); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.loaderData).toEqual({}); + expect(t.router.state.errors).toEqual({ + lazy: "STATIC LOADER ERROR", + }); + expect(lazyStub).toHaveBeenCalledTimes(1); + consoleWarn.mockReset(); + }); + }); + + it("bubbles errors thrown from static loaders before lazy property has resolved if lazy 'hasErrorBoundary' is falsy", async () => { + let consoleWarn = jest.spyOn(console, "warn"); + let { lazyStub, lazyDeferred } = createLazyStub(); + let t = setup({ + routes: [ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "lazy", + loader: true, + lazy: { + hasErrorBoundary: lazyStub, + }, + }, + ], + }, + ], + }); + expect(lazyStub).not.toHaveBeenCalled(); + + let A = await t.navigate("/lazy"); + + await A.loaders.lazy.reject("STATIC LOADER ERROR"); + expect(t.router.state.navigation.state).toBe("loading"); + + // We shouldn't bubble the loader error until after this resolves + // so we know if it has a boundary or not + await lazyDeferred.resolve(null); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.loaderData).toEqual({}); + expect(t.router.state.errors).toEqual({ + root: "STATIC LOADER ERROR", + }); + expect(lazyStub).toHaveBeenCalledTimes(1); + consoleWarn.mockReset(); }); describe("interruptions", () => { - it("runs lazily loaded route loader even if lazy() is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("runs lazily loaded route loader even if lazy function is interrupted", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -623,8 +1491,38 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("runs lazily loaded route action even if lazy() is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("runs lazily loaded route loader even if lazy property is interrupted", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + + await t.navigate("/"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("idle"); + + let lazyLoaderStub = jest.fn(() => "LAZY LOADER"); + await lazyDeferred.resolve(lazyLoaderStub); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + + // Ensure the lazy route object update still happened + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.loader).toBe(lazyLoaderStub); + + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("runs lazily loaded route action even if lazy function is interrupted", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -655,8 +1553,47 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("runs lazily loaded route loader on fetcher.load() even if lazy() is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("runs lazily loaded route action even if lazy property is interrupted", async () => { + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let routes = createBasicLazyRoutes({ + action: lazyActionStub, + loader: lazyLoaderStub, + }); + let t = setup({ routes }); + expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + await t.navigate("/"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("idle"); + + let actionStub = jest.fn(() => "LAZY ACTION"); + let loaderStub = jest.fn(() => "LAZY LOADER"); + await lazyActionDeferred.resolve(actionStub); + await lazyLoaderDeferred.resolve(loaderStub); + + let lazyRoute = findRouteById(t.router.routes, "lazy"); + expect(actionStub).toHaveBeenCalledTimes(1); + expect(loaderStub).not.toHaveBeenCalled(); + expect(lazyRoute.lazy).toBeUndefined(); + expect(lazyRoute.action).toBe(actionStub); + expect(lazyRoute.loader).toBe(loaderStub); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + }); + + it("runs lazily loaded route loader on fetcher.load() even if lazy function is interrupted", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -684,32 +1621,98 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("runs lazily loaded route action on fetcher.submit() even if lazy() is interrupted", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("runs lazily loaded route loader on fetcher.load() even if lazy property is interrupted", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); let key = "key"; - await t.fetch("/lazy", key, { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); expect(lazyStub).toHaveBeenCalledTimes(1); - await t.fetch("/lazy", key, { - formMethod: "post", - formData: createFormData({}), - }); - expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); expect(lazyStub).toHaveBeenCalledTimes(1); - let actionDeferred = createDeferred(); - let lazyActionStub = jest.fn(() => actionDeferred.promise); - await lazyDeferred.resolve({ - action: lazyActionStub, + let loaderDeferred = createDeferred(); + let loaderStub = jest.fn(() => loaderDeferred.promise); + await lazyDeferred.resolve(loaderStub); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await loaderDeferred.resolve("LAZY LOADER"); + + expect(t.fetchers[key].state).toBe("idle"); + expect(t.fetchers[key].data).toBe("LAZY LOADER"); + expect(loaderStub).toHaveBeenCalledTimes(2); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("runs lazily loaded route action on fetcher.submit() even if lazy function is interrupted", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + let lazyActionStub = jest.fn(() => actionDeferred.promise); + await lazyDeferred.resolve({ + action: lazyActionStub, + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + + await actionDeferred.resolve("LAZY ACTION"); + + expect(t.fetchers[key].state).toBe("idle"); + expect(t.fetchers[key].data).toBe("LAZY ACTION"); + expect(lazyActionStub).toHaveBeenCalledTimes(2); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("runs lazily loaded route action on fetcher.submit() even if lazy property is interrupted", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + action: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), }); expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + let lazyActionStub = jest.fn(() => actionDeferred.promise); + await lazyDeferred.resolve(lazyActionStub); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); await actionDeferred.resolve("LAZY ACTION"); @@ -719,8 +1722,8 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("uses the first-called lazy() execution on repeated loading navigations", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("uses the first-called lazy function execution on repeated loading navigations", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -751,8 +1754,41 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("uses the first-called lazy() execution on repeated submission navigations", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("uses the first-called lazy property execution on repeated loading navigations", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); + await lazyDeferred.resolve(lazyLoaderStub); + + await loaderDeferred.resolve("LAZY LOADER"); + + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + + expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); + + expect(lazyLoaderStub).toHaveBeenCalledTimes(2); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("uses the first-called lazy function execution on repeated submission navigations", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -795,8 +1831,61 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("uses the first-called lazy() execution on repeated fetcher.load calls", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("uses the first-called lazy function property on repeated submission navigations", async () => { + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let routes = createBasicLazyRoutes({ + action: lazyActionStub, + loader: lazyLoaderStub, + }); + let t = setup({ routes }); + expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + + await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + let actionDeferred = createDeferred(); + let loaderStub = jest.fn(() => loaderDeferred.promise); + let actionStub = jest.fn(() => actionDeferred.promise); + await lazyActionDeferred.resolve(actionStub); + await lazyLoaderDeferred.resolve(loaderStub); + + await actionDeferred.resolve("LAZY ACTION"); + await loaderDeferred.resolve("LAZY LOADER"); + + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + + expect(t.router.state.actionData).toEqual({ lazy: "LAZY ACTION" }); + expect(t.router.state.loaderData).toEqual({ lazy: "LAZY LOADER" }); + + expect(actionStub).toHaveBeenCalledTimes(2); + expect(loaderStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + }); + + it("uses the first-called lazy function execution on repeated fetcher.load calls", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -824,10 +1913,41 @@ describe("lazily loaded route modules", () => { expect(lazyLoaderStub).toHaveBeenCalledTimes(2); expect(lazyStub).toHaveBeenCalledTimes(1); }); + + it("uses the first-called lazy property execution on repeated fetcher.load calls", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + let lazyLoaderStub = jest.fn(() => loaderDeferred.promise); + await lazyDeferred.resolve(lazyLoaderStub); + + expect(t.fetchers[key].state).toBe("loading"); + + await loaderDeferred.resolve("LAZY LOADER"); + + expect(t.fetchers[key].state).toBe("idle"); + expect(t.fetchers[key].data).toBe("LAZY LOADER"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(2); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); }); describe("errors", () => { - it("handles errors when failing to load lazy route modules on initialization", async () => { + it("handles errors when failing to resolve lazy route function on initialization", async () => { let lazyDeferred = createDeferred(); let router = createRouter({ history: createMemoryHistory({ initialEntries: ["/lazy"] }), @@ -856,8 +1976,39 @@ describe("lazily loaded route modules", () => { expect(router.state.initialized).toBe(true); }); - it("handles errors when failing to load lazy route modules on loading navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("handles errors when failing to resolve lazy route property on initialization", async () => { + let lazyDeferred = createDeferred(); + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/lazy"] }), + routes: [ + { + id: "root", + path: "/", + hasErrorBoundary: true, + children: [ + { + id: "lazy", + path: "lazy", + lazy: { + loader: () => lazyDeferred.promise, + }, + }, + ], + }, + ], + }).initialize(); + + expect(router.state.initialized).toBe(false); + lazyDeferred.reject(new Error("LAZY PROPERTY ERROR")); + await tick(); + expect(router.state.errors).toEqual({ + root: new Error("LAZY PROPERTY ERROR"), + }); + expect(router.state.initialized).toBe(true); + }); + + it("handles errors when failing to resolve lazy route function on loading navigation", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -877,8 +2028,32 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("handles loader errors from lazy route modules when the route has an error boundary", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("handles errors when failing to resolve lazy route property on loading navigation", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await lazyDeferred.reject(new Error("LAZY PROPERTY ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + + expect(t.router.state.loaderData).toEqual({}); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY PROPERTY ERROR"), + }); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("handles loader errors from lazy route functions when the route has an error boundary", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -905,8 +2080,46 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("bubbles loader errors from in lazy route modules when the route does not specify an error boundary", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("handles loader errors from lazy route properties when the route has an error boundary", async () => { + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let { + lazyStub: lazyHasErrorBoundaryStub, + lazyDeferred: lazyHasErrorBoundaryDeferred, + } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyLoaderStub, + hasErrorBoundary: lazyHasErrorBoundaryStub, + }); + let t = setup({ routes }); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundaryStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + await lazyHasErrorBoundaryDeferred.resolve(() => true); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); + + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.errors).toEqual({ + lazy: new Error("LAZY LOADER ERROR"), + }); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + }); + + it("bubbles loader errors from in lazy route functions when the route does not specify an error boundary", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -932,8 +2145,36 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("bubbles loader errors from lazy route modules when the route specifies hasErrorBoundary:false", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("bubbles loader errors from in lazy route properties when the route does not specify an error boundary", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + await lazyDeferred.resolve(() => loaderDeferred.promise); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + + await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); + + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("bubbles loader errors from lazy route functions when the route specifies hasErrorBoundary:false", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -960,8 +2201,112 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("handles errors when failing to load lazy route modules on submission navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("bubbles loader errors from lazy route properties when the route specifies hasErrorBoundary:false", async () => { + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let { + lazyStub: lazyHasErrorBoundaryStub, + lazyDeferred: lazyHasErrorBoundaryDeferred, + } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyLoaderStub, + hasErrorBoundary: lazyHasErrorBoundaryStub, + }); + let t = setup({ routes }); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundaryStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + await lazyHasErrorBoundaryDeferred.resolve(false); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + + await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); + + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + }); + + it("bubbles loader errors from lazy route properties when the route specifies hasErrorBoundary:null", async () => { + let { lazyStub: lazyLoaderStub, lazyDeferred: lazyLoaderDeferred } = + createLazyStub(); + let { + lazyStub: lazyHasErrorBoundaryStub, + lazyDeferred: lazyHasErrorBoundaryDeferred, + } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyLoaderStub, + hasErrorBoundary: lazyHasErrorBoundaryStub, + }); + let t = setup({ routes }); + expect(lazyLoaderStub).not.toHaveBeenCalled(); + expect(lazyHasErrorBoundaryStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy"); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + await lazyLoaderDeferred.resolve(() => loaderDeferred.promise); + await lazyHasErrorBoundaryDeferred.resolve(null); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("loading"); + + await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); + + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); + expect(lazyLoaderStub).toHaveBeenCalledTimes(1); + expect(lazyHasErrorBoundaryStub).toHaveBeenCalledTimes(1); + }); + + it("handles errors when failing to resolve lazy route functions on submission navigation", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.loaderData).toEqual({}); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("handles errors when failing to resolve lazy route properties on submission navigation", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -980,15 +2325,128 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY FUNCTION ERROR"), }); - expect(t.router.state.actionData).toEqual(null); - expect(t.router.state.loaderData).toEqual({}); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.loaderData).toEqual({}); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("handles action errors from lazy route functions on submission navigation", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + await lazyDeferred.resolve({ + action: () => actionDeferred.promise, + hasErrorBoundary: true, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + await actionDeferred.reject(new Error("LAZY ACTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.errors).toEqual({ + lazy: new Error("LAZY ACTION ERROR"), + }); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("handles action errors from lazy route properties on submission navigation", async () => { + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let { + lazyStub: lazyErrorBoundaryStub, + lazyDeferred: lazyErrorBoundaryDeferred, + } = createLazyStub(); + let routes = createBasicLazyRoutes({ + action: lazyActionStub, + hasErrorBoundary: lazyErrorBoundaryStub, + }); + let t = setup({ routes }); + expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyErrorBoundaryStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + await lazyActionDeferred.resolve(() => actionDeferred.promise); + await lazyErrorBoundaryDeferred.resolve(true); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + await actionDeferred.reject(new Error("LAZY ACTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.errors).toEqual({ + lazy: new Error("LAZY ACTION ERROR"), + }); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); + }); + + it("bubbles action errors from lazy route functions when the route specifies hasErrorBoundary:false", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + await t.navigate("/lazy", { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + await lazyDeferred.resolve({ + action: () => actionDeferred.promise, + hasErrorBoundary: false, + }); + expect(t.router.state.location.pathname).toBe("/"); + expect(t.router.state.navigation.state).toBe("submitting"); + + await actionDeferred.reject(new Error("LAZY ACTION ERROR")); + expect(t.router.state.location.pathname).toBe("/lazy"); + expect(t.router.state.navigation.state).toBe("idle"); + expect(t.router.state.actionData).toEqual(null); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY ACTION ERROR"), + }); expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("handles action errors from lazy route modules on submission navigation", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("bubbles action errors from lazy route properties when the route specifies hasErrorBoundary:false", async () => { + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let { + lazyStub: lazyErrorBoundaryStub, + lazyDeferred: lazyErrorBoundaryDeferred, + } = createLazyStub(); + let routes = createBasicLazyRoutes({ + action: lazyActionStub, + hasErrorBoundary: lazyErrorBoundaryStub, + }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyErrorBoundaryStub).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -996,13 +2454,12 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); - await lazyDeferred.resolve({ - action: () => actionDeferred.promise, - hasErrorBoundary: true, - }); + await lazyActionDeferred.resolve(() => actionDeferred.promise); + await lazyErrorBoundaryDeferred.resolve(false); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); @@ -1011,15 +2468,26 @@ describe("lazily loaded route modules", () => { expect(t.router.state.navigation.state).toBe("idle"); expect(t.router.state.actionData).toEqual(null); expect(t.router.state.errors).toEqual({ - lazy: new Error("LAZY ACTION ERROR"), + root: new Error("LAZY ACTION ERROR"), }); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); }); - it("bubbles action errors from lazy route modules when the route specifies hasErrorBoundary:false", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("bubbles action errors from lazy route properties when the route specifies hasErrorBoundary:null", async () => { + let { lazyStub: lazyActionStub, lazyDeferred: lazyActionDeferred } = + createLazyStub(); + let { + lazyStub: lazyErrorBoundaryStub, + lazyDeferred: lazyErrorBoundaryDeferred, + } = createLazyStub(); + let routes = createBasicLazyRoutes({ + action: lazyActionStub, + hasErrorBoundary: lazyErrorBoundaryStub, + }); let t = setup({ routes }); - expect(lazyStub).not.toHaveBeenCalled(); + expect(lazyActionStub).not.toHaveBeenCalled(); + expect(lazyErrorBoundaryStub).not.toHaveBeenCalled(); await t.navigate("/lazy", { formMethod: "post", @@ -1027,13 +2495,12 @@ describe("lazily loaded route modules", () => { }); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); - expect(lazyStub).toHaveBeenCalledTimes(1); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); let actionDeferred = createDeferred(); - await lazyDeferred.resolve({ - action: () => actionDeferred.promise, - hasErrorBoundary: false, - }); + await lazyActionDeferred.resolve(() => actionDeferred.promise); + await lazyErrorBoundaryDeferred.resolve(null); expect(t.router.state.location.pathname).toBe("/"); expect(t.router.state.navigation.state).toBe("submitting"); @@ -1044,11 +2511,33 @@ describe("lazily loaded route modules", () => { expect(t.router.state.errors).toEqual({ root: new Error("LAZY ACTION ERROR"), }); + expect(lazyActionStub).toHaveBeenCalledTimes(1); + expect(lazyErrorBoundaryStub).toHaveBeenCalledTimes(1); + }); + + it("handles errors when failing to load lazy route functions on fetcher.load", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("handles errors when failing to load lazy route modules on fetcher.load", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("handles errors when failing to load lazy route properties on fetcher.load", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -1065,8 +2554,8 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("handles loader errors in lazy route modules on fetcher.load", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("handles loader errors in lazy route functions on fetcher.load", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -1089,8 +2578,57 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("handles errors when failing to load lazy route modules on fetcher.submit", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("handles loader errors in lazy route properties on fetcher.load", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("/lazy", key); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let loaderDeferred = createDeferred(); + await lazyDeferred.resolve(() => loaderDeferred.promise); + expect(t.router.state.fetchers.get(key)?.state).toBe("loading"); + + await loaderDeferred.reject(new Error("LAZY LOADER ERROR")); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("handles errors when failing to load lazy route functions on fetcher.submit", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + await lazyDeferred.reject(new Error("LAZY FUNCTION ERROR")); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY FUNCTION ERROR"), + }); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("handles errors when failing to load lazy route properties on fetcher.submit", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + loader: lazyStub, + }); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -1110,8 +2648,8 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("handles action errors in lazy route modules on fetcher.submit", async () => { - let { routes, lazyStub, lazyDeferred } = createBasicLazyRoutes(); + it("handles action errors in lazy route functions on fetcher.submit", async () => { + let { routes, lazyStub, lazyDeferred } = createBasicLazyFunctionRoutes(); let t = setup({ routes }); expect(lazyStub).not.toHaveBeenCalled(); @@ -1138,7 +2676,36 @@ describe("lazily loaded route modules", () => { expect(lazyStub).toHaveBeenCalledTimes(1); }); - it("throws when failing to load lazy route modules on staticHandler.query()", async () => { + it("handles action errors in lazy route properties on fetcher.submit", async () => { + let { lazyStub, lazyDeferred } = createLazyStub(); + let routes = createBasicLazyRoutes({ + action: lazyStub, + }); + let t = setup({ routes }); + expect(lazyStub).not.toHaveBeenCalled(); + + let key = "key"; + await t.fetch("/lazy", key, { + formMethod: "post", + formData: createFormData({}), + }); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + expect(lazyStub).toHaveBeenCalledTimes(1); + + let actionDeferred = createDeferred(); + await lazyDeferred.resolve(() => actionDeferred.promise); + expect(t.router.state.fetchers.get(key)?.state).toBe("submitting"); + + await actionDeferred.reject(new Error("LAZY ACTION ERROR")); + await tick(); + expect(t.router.state.fetchers.get(key)).toBeUndefined(); + expect(t.router.state.errors).toEqual({ + root: new Error("LAZY ACTION ERROR"), + }); + expect(lazyStub).toHaveBeenCalledTimes(1); + }); + + it("throws when failing to resolve lazy route functions on staticHandler.query()", async () => { let { query } = createStaticHandler([ { id: "root", @@ -1165,7 +2732,36 @@ describe("lazily loaded route modules", () => { }); }); - it("handles loader errors from lazy route modules on staticHandler.query()", async () => { + it("throws when failing to resolve lazy route properties on staticHandler.query()", async () => { + let { query } = createStaticHandler([ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + throw new Error("LAZY PROPERTY ERROR"); + }, + }, + }, + ], + }, + ]); + + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.errors).toEqual({ + root: new Error("LAZY PROPERTY ERROR"), + }); + }); + + it("handles loader errors from lazy route functions on staticHandler.query()", async () => { let { query } = createStaticHandler([ { id: "root", @@ -1201,7 +2797,46 @@ describe("lazily loaded route modules", () => { }); }); - it("bubbles loader errors from lazy route modules on staticHandler.query() when hasErrorBoundary is resolved as false", async () => { + it("handles loader errors from lazy route properties on staticHandler.query()", async () => { + let { query } = createStaticHandler([ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + await tick(); + return async () => { + throw new Error("LAZY LOADER ERROR"); + }; + }, + hasErrorBoundary: async () => { + await tick(); + return true; + }, + }, + }, + ], + }, + ]); + + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ + root: null, + }); + expect(context.errors).toEqual({ + lazy: new Error("LAZY LOADER ERROR"), + }); + }); + + it("bubbles loader errors from lazy route functions on staticHandler.query() when hasErrorBoundary is resolved as false", async () => { let { query } = createStaticHandler([ { id: "root", @@ -1214,6 +2849,7 @@ describe("lazily loaded route modules", () => { await tick(); return { async loader() { + await tick(); throw new Error("LAZY LOADER ERROR"); }, hasErrorBoundary: false, @@ -1237,7 +2873,85 @@ describe("lazily loaded route modules", () => { }); }); - it("throws when failing to load lazy route modules on staticHandler.queryRoute()", async () => { + it("bubbles loader errors from lazy route properties on staticHandler.query() when hasErrorBoundary is resolved as false", async () => { + let { query } = createStaticHandler([ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + await tick(); + return async () => { + throw new Error("LAZY LOADER ERROR"); + }; + }, + hasErrorBoundary: async () => { + await tick(); + return false; + }, + }, + }, + ], + }, + ]); + + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ + root: null, + }); + expect(context.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); + }); + + it("bubbles loader errors from lazy route properties on staticHandler.query() when hasErrorBoundary is resolved as null", async () => { + let { query } = createStaticHandler([ + { + id: "root", + path: "/", + children: [ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + await tick(); + return async () => { + throw new Error("LAZY LOADER ERROR"); + }; + }, + hasErrorBoundary: async () => { + await tick(); + return null; + }, + }, + }, + ], + }, + ]); + + let context = await query(createRequest("/lazy")); + invariant( + !(context instanceof Response), + "Expected a StaticContext instance" + ); + expect(context.loaderData).toEqual({ + root: null, + }); + expect(context.errors).toEqual({ + root: new Error("LAZY LOADER ERROR"), + }); + }); + + it("throws when failing to resolve lazy route functions on staticHandler.queryRoute()", async () => { let { queryRoute } = createStaticHandler([ { id: "lazy", @@ -1258,7 +2972,30 @@ describe("lazily loaded route modules", () => { expect(err?.message).toBe("LAZY FUNCTION ERROR"); }); - it("handles loader errors in lazy route modules on staticHandler.queryRoute()", async () => { + it("throws when failing to resolve lazy route properties on staticHandler.queryRoute()", async () => { + let { queryRoute } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + throw new Error("LAZY PROPERTY ERROR"); + }, + }, + }, + ]); + + let err; + try { + await queryRoute(createRequest("/lazy")); + } catch (_err) { + err = _err; + } + + expect(err?.message).toBe("LAZY PROPERTY ERROR"); + }); + + it("handles loader errors in lazy route functions on staticHandler.queryRoute()", async () => { let { queryRoute } = createStaticHandler([ { id: "lazy", @@ -1283,5 +3020,31 @@ describe("lazily loaded route modules", () => { expect(err?.message).toBe("LAZY LOADER ERROR"); }); + + it("handles loader errors in lazy route properties on staticHandler.queryRoute()", async () => { + let { queryRoute } = createStaticHandler([ + { + id: "lazy", + path: "/lazy", + lazy: { + loader: async () => { + await tick(); + return async () => { + throw new Error("LAZY LOADER ERROR"); + }; + }, + }, + }, + ]); + + let err; + try { + await queryRoute(createRequest("/lazy")); + } catch (_err) { + err = _err; + } + + expect(err?.message).toBe("LAZY LOADER ERROR"); + }); }); }); diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index cd01a0f166..6a6c8ee096 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -16,7 +16,7 @@ import type { AgnosticPatchRoutesOnNavigationFunction, AgnosticPatchRoutesOnNavigationFunctionArgs, AgnosticRouteMatch, - LazyRouteFunction, + LazyRouteDefinition, TrackedPromise, } from "./router/utils"; @@ -27,7 +27,6 @@ export interface IndexRouteObject { path?: AgnosticIndexRouteObject["path"]; id?: AgnosticIndexRouteObject["id"]; unstable_middleware?: AgnosticIndexRouteObject["unstable_middleware"]; - unstable_lazyMiddleware?: AgnosticIndexRouteObject["unstable_lazyMiddleware"]; loader?: AgnosticIndexRouteObject["loader"]; action?: AgnosticIndexRouteObject["action"]; hasErrorBoundary?: AgnosticIndexRouteObject["hasErrorBoundary"]; @@ -41,7 +40,7 @@ export interface IndexRouteObject { Component?: React.ComponentType | null; HydrateFallback?: React.ComponentType | null; ErrorBoundary?: React.ComponentType | null; - lazy?: LazyRouteFunction; + lazy?: LazyRouteDefinition; } export interface NonIndexRouteObject { @@ -49,7 +48,6 @@ export interface NonIndexRouteObject { path?: AgnosticNonIndexRouteObject["path"]; id?: AgnosticNonIndexRouteObject["id"]; unstable_middleware?: AgnosticNonIndexRouteObject["unstable_middleware"]; - unstable_lazyMiddleware?: AgnosticNonIndexRouteObject["unstable_lazyMiddleware"]; loader?: AgnosticNonIndexRouteObject["loader"]; action?: AgnosticNonIndexRouteObject["action"]; hasErrorBoundary?: AgnosticNonIndexRouteObject["hasErrorBoundary"]; @@ -63,7 +61,7 @@ export interface NonIndexRouteObject { Component?: React.ComponentType | null; HydrateFallback?: React.ComponentType | null; ErrorBoundary?: React.ComponentType | null; - lazy?: LazyRouteFunction; + lazy?: LazyRouteDefinition; } export type RouteObject = IndexRouteObject | NonIndexRouteObject; diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 16dc90f9df..27cb9feb2f 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -423,25 +423,6 @@ export function createClientRoutes( prefetchStylesAndCallHandler(() => { return fetchServerLoader(singleFetch); }); - } else if (route.clientLoaderModule) { - dataRoute.loader = async ( - args: LoaderFunctionArgs, - singleFetch?: unknown - ) => { - invariant(route.clientLoaderModule); - let { clientLoader } = await import( - /* @vite-ignore */ - /* webpackIgnore: true */ - route.clientLoaderModule - ); - return clientLoader({ - ...args, - async serverLoader() { - preventInvalidServerHandlerCall("loader", route); - return fetchServerLoader(singleFetch); - }, - }); - }; } if (!route.hasClientAction) { dataRoute.action = (_: ActionFunctionArgs, singleFetch?: unknown) => @@ -451,111 +432,111 @@ export function createClientRoutes( } return fetchServerAction(singleFetch); }); - } else if (route.clientActionModule) { - dataRoute.action = async ( - args: ActionFunctionArgs, - singleFetch?: unknown - ) => { - invariant(route.clientActionModule); - prefetchRouteModuleChunks(route); - let { clientAction } = await import( - /* @vite-ignore */ - /* webpackIgnore: true */ - route.clientActionModule - ); - return clientAction({ - ...args, - async serverAction() { - preventInvalidServerHandlerCall("action", route); - return fetchServerAction(singleFetch); - }, - }); - }; } - if (route.hasClientMiddleware) { - dataRoute.unstable_lazyMiddleware = async () => { - invariant(route); - let clientMiddlewareModule = await import( - /* @vite-ignore */ - /* webpackIgnore: true */ - route.clientMiddlewareModule || route.module - ); - invariant( - clientMiddlewareModule?.unstable_clientMiddleware, - "No `unstable_clientMiddleware` export in chunk" - ); - return clientMiddlewareModule.unstable_clientMiddleware; - }; - } - - // Load all other modules via route.lazy() - dataRoute.lazy = async () => { - if (route.clientLoaderModule || route.clientActionModule) { - // If a client loader/action chunk is present, we push the loading of - // the main route chunk to the next tick to ensure the downloading of - // loader/action chunks takes precedence. This can be seen via their - // order in the network tab. Also note that since this is happening - // within `route.lazy`, this imperceptible delay only happens on the - // first load of this route. - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - let modPromise = loadRouteModuleWithBlockingLinks( - route, - routeModulesCache - ); - prefetchRouteModuleChunks(route); - let mod = await modPromise; - - let lazyRoute: Partial = { ...mod }; - if (mod.clientLoader) { - let clientLoader = mod.clientLoader; - lazyRoute.loader = ( - args: LoaderFunctionArgs, - singleFetch?: unknown - ) => - clientLoader({ - ...args, - async serverLoader() { - preventInvalidServerHandlerCall("loader", route); - return fetchServerLoader(singleFetch); - }, - }); - } - - if (mod.clientAction) { - let clientAction = mod.clientAction; - lazyRoute.action = ( - args: ActionFunctionArgs, - singleFetch?: unknown - ) => - clientAction({ - ...args, - async serverAction() { - preventInvalidServerHandlerCall("action", route); - return fetchServerAction(singleFetch); - }, - }); + let lazyRoutePromise: + | ReturnType + | undefined; + async function getLazyRoute() { + if (lazyRoutePromise) { + return await lazyRoutePromise; } + lazyRoutePromise = (async () => { + if (route.clientLoaderModule || route.clientActionModule) { + // If a client loader/action chunk is present, we push the loading of + // the main route chunk to the next tick to ensure the downloading of + // loader/action chunks takes precedence. This can be seen via their + // order in the network tab. Also note that since this is happening + // within `route.lazy`, this imperceptible delay only happens on the + // first load of this route. + await new Promise((resolve) => setTimeout(resolve, 0)); + } - return { - ...(lazyRoute.loader ? { loader: lazyRoute.loader } : {}), - ...(lazyRoute.action ? { action: lazyRoute.action } : {}), + let routeModulePromise = loadRouteModuleWithBlockingLinks( + route, + routeModulesCache + ); + prefetchRouteModuleChunks(route); + return await routeModulePromise; + })(); + return await lazyRoutePromise; + } - hasErrorBoundary: lazyRoute.hasErrorBoundary, - shouldRevalidate: getShouldRevalidateFunction( + dataRoute.lazy = { + loader: route.hasClientLoader + ? async () => { + let { clientLoader } = route.clientLoaderModule + ? await import( + /* @vite-ignore */ + /* webpackIgnore: true */ + route.clientLoaderModule + ) + : await getLazyRoute(); + invariant(clientLoader, "No `clientLoader` export found"); + return (args: LoaderFunctionArgs, singleFetch?: unknown) => + clientLoader({ + ...args, + async serverLoader() { + preventInvalidServerHandlerCall("loader", route); + return fetchServerLoader(singleFetch); + }, + }); + } + : undefined, + action: route.hasClientAction + ? async () => { + let clientActionPromise = route.clientActionModule + ? import( + /* @vite-ignore */ + /* webpackIgnore: true */ + route.clientActionModule + ) + : getLazyRoute(); + prefetchRouteModuleChunks(route); + let { clientAction } = await clientActionPromise; + invariant(clientAction, "No `clientAction` export found"); + return (args: ActionFunctionArgs, singleFetch?: unknown) => + clientAction({ + ...args, + async serverAction() { + preventInvalidServerHandlerCall("action", route); + return fetchServerAction(singleFetch); + }, + }); + } + : undefined, + unstable_middleware: route.hasClientMiddleware + ? async () => { + let { unstable_clientMiddleware } = route.clientMiddlewareModule + ? await import( + /* @vite-ignore */ + /* webpackIgnore: true */ + route.clientMiddlewareModule + ) + : await getLazyRoute(); + invariant( + unstable_clientMiddleware, + "No `unstable_clientMiddleware` export found" + ); + return unstable_clientMiddleware; + } + : undefined, + shouldRevalidate: async () => { + let lazyRoute = await getLazyRoute(); + return getShouldRevalidateFunction( lazyRoute, route, ssr, needsRevalidation - ), - handle: lazyRoute.handle, - // No need to wrap these in layout since the root route is never - // loaded via route.lazy() - Component: lazyRoute.Component, - ErrorBoundary: lazyRoute.ErrorBoundary, - }; + ); + }, + handle: async () => (await getLazyRoute()).handle, + // No need to wrap these in layout since the root route is never + // loaded via route.lazy() + Component: async () => (await getLazyRoute()).Component, + ErrorBoundary: route.hasErrorBoundary + ? async () => (await getLazyRoute()).ErrorBoundary + : undefined, }; } diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 216ea3c585..fcd5d7833e 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -20,7 +20,6 @@ import type { FormMethod, HTMLFormMethod, DataStrategyResult, - UnsupportedLazyRouteFunctionKey, MapRoutePropertiesFunction, MaybePromise, MutationFormMethod, @@ -48,7 +47,8 @@ import { convertRoutesToDataRoutes, getPathContributingMatches, getResolveToMatches, - unsupportedLazyRouteFunctionKeys, + isUnsupportedLazyRouteObjectKey, + isUnsupportedLazyRouteFunctionKey, isRouteErrorResponse, joinPaths, matchRoutes, @@ -3484,7 +3484,9 @@ export function createStaticHandler( if ( respond && matches.some( - (m) => m.route.unstable_middleware || m.route.unstable_lazyMiddleware + (m) => + m.route.unstable_middleware || + (typeof m.route.lazy === "object" && m.route.lazy.unstable_middleware) ) ) { invariant( @@ -3493,7 +3495,11 @@ export function createStaticHandler( "`requestContext` must be an instance of `unstable_RouterContextProvider`" ); try { - await loadLazyMiddlewareForMatches(matches, manifest); + await loadLazyMiddlewareForMatches( + matches, + manifest, + mapRouteProperties + ); let renderedStaticContext: StaticHandlerContext | undefined; let response = await runMiddlewarePipeline( { @@ -3683,7 +3689,9 @@ export function createStaticHandler( if ( respond && matches.some( - (m) => m.route.unstable_middleware || m.route.unstable_lazyMiddleware + (m) => + m.route.unstable_middleware || + (typeof m.route.lazy === "object" && m.route.lazy.unstable_middleware) ) ) { invariant( @@ -3691,7 +3699,7 @@ export function createStaticHandler( "When using middleware in `staticHandler.queryRoute()`, any provided " + "`requestContext` must be an instance of `unstable_RouterContextProvider`" ); - await loadLazyMiddlewareForMatches(matches, manifest); + await loadLazyMiddlewareForMatches(matches, manifest, mapRouteProperties); let response = await runMiddlewarePipeline( { request, @@ -4839,153 +4847,225 @@ function isSameRoute( ); } -const lazyRouteFunctionCache = new WeakMap< +const lazyRoutePropertyCache = new WeakMap< AgnosticDataRouteObject, - Promise + Partial>> >(); -/** - * Execute route.lazy() methods to lazily load route modules (loader, action, - * shouldRevalidate) and update the routeManifest in place which shares objects - * with dataRoutes so those get updated as well. - */ -async function loadLazyRouteModule( - route: AgnosticDataRouteObject, - mapRouteProperties: MapRoutePropertiesFunction, - manifest: RouteManifest -) { +const loadLazyRouteProperty = ({ + key, + route, + manifest, + mapRouteProperties, +}: { + key: keyof AgnosticDataRouteObject; + route: AgnosticDataRouteObject; + manifest: RouteManifest; + mapRouteProperties: MapRoutePropertiesFunction; +}): Promise | undefined => { let routeToUpdate = manifest[route.id]; invariant(routeToUpdate, "No route found in manifest"); - if (!route.lazy) { + if (!routeToUpdate.lazy || typeof routeToUpdate.lazy !== "object") { return; } - // Check if we have a cached promise from a previous call - let cachedPromise = lazyRouteFunctionCache.get(routeToUpdate); - if (cachedPromise) { - await cachedPromise; + let lazyFn = routeToUpdate.lazy[key as keyof typeof routeToUpdate.lazy]; + + if (!lazyFn) { return; } - // We use `.then` to chain additional logic to the lazy route promise so that - // the consumer's lazy route logic is coupled to our logic for updating the - // route in place in a single task. This ensures that the cached promise - // contains all logic for managing the lazy route. This chained promise is - // then awaited so that consumers of this function see the updated route. - let lazyRoutePromise = route.lazy().then((lazyRoute) => { - // Here we update the route in place. This should be safe because there's - // no way we could yet be sitting on this route as we can't get there - // without resolving lazy() first. - // - // This is different than the HMR "update" use-case where we may actively be - // on the route being updated. The main concern boils down to "does this - // mutation affect any ongoing navigations or any current state.matches - // values?". If not, it should be safe to update in place. - let routeUpdates: Record = {}; - for (let lazyRouteProperty in lazyRoute) { - let staticRouteValue = - routeToUpdate[lazyRouteProperty as keyof typeof routeToUpdate]; - - let isPropertyStaticallyDefined = - staticRouteValue !== undefined && - // This property isn't static since it should always be updated based - // on the route updates - lazyRouteProperty !== "hasErrorBoundary"; + let cache = lazyRoutePropertyCache.get(routeToUpdate); + if (!cache) { + cache = {}; + lazyRoutePropertyCache.set(routeToUpdate, cache); + } - warning( - !isPropertyStaticallyDefined, - `Route "${routeToUpdate.id}" has a static property "${lazyRouteProperty}" ` + - `defined but its lazy function is also returning a value for this property. ` + - `The lazy route property "${lazyRouteProperty}" will be ignored.` - ); + let cachedPromise = cache[key]; + if (cachedPromise) { + return cachedPromise; + } + + let propertyPromise = (async () => { + let isUnsupported = isUnsupportedLazyRouteObjectKey(key); + let staticRouteValue = routeToUpdate[key as keyof typeof routeToUpdate]; + let isStaticallyDefined = + staticRouteValue !== undefined && key !== "hasErrorBoundary"; + if (isUnsupported) { warning( - !unsupportedLazyRouteFunctionKeys.has( - lazyRouteProperty as UnsupportedLazyRouteFunctionKey - ), + !isUnsupported, "Route property " + - lazyRouteProperty + - " is not a supported property to be returned from a lazy route function. This property will be ignored." + key + + " is not a supported lazy route property. This property will be ignored." + ); + cache[key] = Promise.resolve(); + } else if (isStaticallyDefined) { + warning( + false, + `Route "${routeToUpdate.id}" has a static property "${key}" ` + + `defined. The lazy property will be ignored.` ); + } else { + let value = await lazyFn(); + if (value != null) { + Object.assign(routeToUpdate, { [key]: value }); + Object.assign(routeToUpdate, mapRouteProperties(routeToUpdate)); + } + } + // Clean up lazy property and clean up lazy object if it's now empty + if (typeof routeToUpdate.lazy === "object") { + routeToUpdate.lazy[key as keyof typeof routeToUpdate.lazy] = undefined; if ( - !isPropertyStaticallyDefined && - !unsupportedLazyRouteFunctionKeys.has( - lazyRouteProperty as UnsupportedLazyRouteFunctionKey - ) + Object.values(routeToUpdate.lazy).every((value) => value === undefined) ) { - routeUpdates[lazyRouteProperty] = - lazyRoute[lazyRouteProperty as keyof typeof lazyRoute]; + routeToUpdate.lazy = undefined; } } + })(); - // Mutate the route with the provided updates. Do this first so we pass - // the updated version to mapRouteProperties - Object.assign(routeToUpdate, routeUpdates); - - // Mutate the `hasErrorBoundary` property on the route based on the route - // updates and remove the `lazy` function so we don't resolve the lazy - // route again. - Object.assign(routeToUpdate, { - // To keep things framework agnostic, we use the provided `mapRouteProperties` - // function to set the framework-aware properties (`element`/`hasErrorBoundary`) - // since the logic will differ between frameworks. - ...mapRouteProperties(routeToUpdate), - lazy: undefined, - }); - }); + cache[key] = propertyPromise; + return propertyPromise; +}; - lazyRouteFunctionCache.set(routeToUpdate, lazyRoutePromise); - await lazyRoutePromise; -} +const lazyRouteFunctionCache = new WeakMap< + AgnosticDataRouteObject, + Promise +>(); -async function loadLazyMiddleware( +/** + * Execute route.lazy functions to lazily load route modules (loader, action, + * shouldRevalidate) and update the routeManifest in place which shares objects + * with dataRoutes so those get updated as well. + */ +async function loadLazyRoute( route: AgnosticDataRouteObject, - manifest: RouteManifest + manifest: RouteManifest, + mapRouteProperties: MapRoutePropertiesFunction ) { - if (!route.unstable_lazyMiddleware) { - return; - } - let routeToUpdate = manifest[route.id]; invariant(routeToUpdate, "No route found in manifest"); - if (routeToUpdate.unstable_middleware) { - warning( - false, - `Route "${routeToUpdate.id}" has a static property "unstable_middleware" ` + - `defined. The "unstable_lazyMiddleware" function will be ignored.` - ); - } else { - let middleware = await route.unstable_lazyMiddleware(); + if (!route.lazy) { + return; + } - // If the `unstable_lazyMiddleware` function was executed and removed by - // another parallel call then we can return - first call to finish wins - // because the return value is expected to be static - if (!route.unstable_lazyMiddleware) { + if (typeof route.lazy === "function") { + // Check if we have a cached promise from a previous call + let cachedPromise = lazyRouteFunctionCache.get(routeToUpdate); + if (cachedPromise) { + await cachedPromise; return; } - if (!routeToUpdate.unstable_middleware) { - routeToUpdate.unstable_middleware = middleware; - } + // We use `.then` to chain additional logic to the lazy route promise so that + // the consumer's lazy route logic is coupled to our logic for updating the + // route in place in a single task. This ensures that the cached promise + // contains all logic for managing the lazy route. This chained promise is + // then awaited so that consumers of this function see the updated route. + let lazyRoutePromise = route.lazy().then((lazyRoute) => { + // Here we update the route in place. This should be safe because there's + // no way we could yet be sitting on this route as we can't get there + // without resolving lazy() first. + // + // This is different than the HMR "update" use-case where we may actively be + // on the route being updated. The main concern boils down to "does this + // mutation affect any ongoing navigations or any current state.matches + // values?". If not, it should be safe to update in place. + let routeUpdates: Record = {}; + for (let lazyRouteProperty in lazyRoute) { + let lazyValue = lazyRoute[lazyRouteProperty as keyof typeof lazyRoute]; + + if (lazyValue === undefined) { + continue; + } + + let isUnsupported = + isUnsupportedLazyRouteFunctionKey(lazyRouteProperty); + let staticRouteValue = + routeToUpdate[lazyRouteProperty as keyof typeof routeToUpdate]; + let isStaticallyDefined = + staticRouteValue !== undefined && + // This property isn't static since it should always be updated based + // on the route updates + lazyRouteProperty !== "hasErrorBoundary"; + + if (isUnsupported) { + warning( + !isUnsupported, + "Route property " + + lazyRouteProperty + + " is not a supported property to be returned from a lazy route function. This property will be ignored." + ); + } else if (isStaticallyDefined) { + warning( + !isStaticallyDefined, + `Route "${routeToUpdate.id}" has a static property "${lazyRouteProperty}" ` + + `defined but its lazy function is also returning a value for this property. ` + + `The lazy route property "${lazyRouteProperty}" will be ignored.` + ); + } else { + routeUpdates[lazyRouteProperty] = lazyValue; + } + } + + // Mutate the route with the provided updates. Do this first so we pass + // the updated version to mapRouteProperties + Object.assign(routeToUpdate, routeUpdates); + + // Mutate the `hasErrorBoundary` property on the route based on the route + // updates and remove the `lazy` function so we don't resolve the lazy + // route again. + Object.assign(routeToUpdate, { + // To keep things framework agnostic, we use the provided `mapRouteProperties` + // function to set the framework-aware properties (`element`/`hasErrorBoundary`) + // since the logic will differ between frameworks. + ...mapRouteProperties(routeToUpdate), + lazy: undefined, + }); + }); + + lazyRouteFunctionCache.set(routeToUpdate, lazyRoutePromise); + await lazyRoutePromise; + + return; } - routeToUpdate.unstable_lazyMiddleware = undefined; + let lazyKeys = Object.keys(route.lazy) as Array; + + await Promise.all( + lazyKeys.map((key) => + loadLazyRouteProperty({ + key, + route, + manifest, + mapRouteProperties, + }) + ) + ); } function loadLazyMiddlewareForMatches( matches: AgnosticDataRouteMatch[], - manifest: RouteManifest + manifest: RouteManifest, + mapRouteProperties: MapRoutePropertiesFunction ): Promise | void { let promises = matches - .map((m) => - m.route.unstable_lazyMiddleware - ? loadLazyMiddleware(m.route, manifest) - : undefined - ) - .filter(Boolean); + .map(({ route }) => { + if (typeof route.lazy !== "object" || !route.lazy.unstable_middleware) { + return undefined; + } + + return loadLazyRouteProperty({ + key: "unstable_middleware", + route, + manifest, + mapRouteProperties, + }); + }) + .filter((p): p is NonNullable => p != null); return promises.length > 0 ? Promise.all(promises) : undefined; } @@ -5179,10 +5259,14 @@ async function callDataStrategyImpl( ): Promise> { // Ensure all lazy/lazyMiddleware async functions are kicked off in parallel // before we await them where needed below - let loadMiddlewarePromise = loadLazyMiddlewareForMatches(matches, manifest); - let loadRouteDefinitionsPromises = matches.map((m) => + let loadMiddlewarePromise = loadLazyMiddlewareForMatches( + matches, + manifest, + mapRouteProperties + ); + let loadLazyRoutePromises = matches.map((m) => m.route.lazy - ? loadLazyRouteModule(m.route, mapRouteProperties, manifest) + ? loadLazyRoute(m.route, manifest, mapRouteProperties) : undefined ); @@ -5192,7 +5276,7 @@ async function callDataStrategyImpl( } let dsMatches = matches.map((match, i) => { - let loadRoutePromise = loadRouteDefinitionsPromises[i]; + let loadRoutePromise = loadLazyRoutePromises[i]; let shouldLoad = matchesToLoad.some((m) => m.route.id === match.route.id); // `resolve` encapsulates route.lazy(), executing the loader/action, // and mapping return values/thrown errors to a `DataStrategyResult`. Users @@ -5240,7 +5324,7 @@ async function callDataStrategyImpl( // it to bubble up from the `await loadRoutePromise` in `callLoaderOrAction` - // called from `match.resolve()` try { - await Promise.all(loadRouteDefinitionsPromises); + await Promise.all(loadLazyRoutePromises); } catch (e) { // No-op } diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 977185daf7..c375d3c42c 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -388,21 +388,42 @@ export interface MapRoutePropertiesFunction { } /** - * Keys we cannot change from within a lazy() function. We spread all other keys + * Keys we cannot change from within a lazy object. We spread all other keys * onto the route. Either they're meaningful to the router, or they'll get * ignored. */ -export type UnsupportedLazyRouteFunctionKey = +type UnsupportedLazyRouteObjectKey = | "lazy" | "caseSensitive" | "path" | "id" | "index" - | "unstable_middleware" - | "unstable_lazyMiddleware" | "children"; +const unsupportedLazyRouteObjectKeys = new Set([ + "lazy", + "caseSensitive", + "path", + "id", + "index", + "children", +]); +export function isUnsupportedLazyRouteObjectKey( + key: string +): key is UnsupportedLazyRouteObjectKey { + return unsupportedLazyRouteObjectKeys.has( + key as UnsupportedLazyRouteObjectKey + ); +} -export const unsupportedLazyRouteFunctionKeys = +/** + * Keys we cannot change from within a lazy() function. We spread all other keys + * onto the route. Either they're meaningful to the router, or they'll get + * ignored. + */ +type UnsupportedLazyRouteFunctionKey = + | UnsupportedLazyRouteObjectKey + | "unstable_middleware"; +const unsupportedLazyRouteFunctionKeys = new Set([ "lazy", "caseSensitive", @@ -410,28 +431,40 @@ export const unsupportedLazyRouteFunctionKeys = "id", "index", "unstable_middleware", - "unstable_lazyMiddleware", "children", ]); +export function isUnsupportedLazyRouteFunctionKey( + key: string +): key is UnsupportedLazyRouteFunctionKey { + return unsupportedLazyRouteFunctionKeys.has( + key as UnsupportedLazyRouteFunctionKey + ); +} -type RequireOne = Exclude< - { - [K in keyof T]: K extends Key ? Omit & Required> : never; - }[keyof T], - undefined ->; +/** + * lazy object to load route properties, which can add non-matching + * related properties to a route + */ +export type LazyRouteObject = { + [K in keyof R as K extends UnsupportedLazyRouteObjectKey + ? never + : K]?: () => Promise; +}; /** * lazy() function to load a route definition, which can add non-matching * related properties to a route */ export interface LazyRouteFunction { - (): Promise>>; + (): Promise< + Omit & + Partial> + >; } -interface LazyMiddlewareFunction { - (): Promise; -} +export type LazyRouteDefinition = + | LazyRouteObject + | LazyRouteFunction; /** * Base RouteObject with common props shared by all types of routes @@ -441,13 +474,12 @@ type AgnosticBaseRouteObject = { path?: string; id?: string; unstable_middleware?: unstable_MiddlewareFunction[]; - unstable_lazyMiddleware?: LazyMiddlewareFunction; loader?: LoaderFunction | boolean; action?: ActionFunction | boolean; hasErrorBoundary?: boolean; shouldRevalidate?: ShouldRevalidateFunction; handle?: any; - lazy?: LazyRouteFunction; + lazy?: LazyRouteDefinition; }; /**