diff --git a/.changeset/large-shoes-live.md b/.changeset/large-shoes-live.md new file mode 100644 index 0000000000..5b493a09b4 --- /dev/null +++ b/.changeset/large-shoes-live.md @@ -0,0 +1,5 @@ +--- +"@react-router/dev": patch +--- + +When both `future.unstable_middleware` and `future.unstable_splitRouteModules` are enabled, split `unstable_clientMiddleware` route exports into separate chunks when possible diff --git a/.changeset/silent-apples-return.md b/.changeset/silent-apples-return.md new file mode 100644 index 0000000000..ce437d030d --- /dev/null +++ b/.changeset/silent-apples-return.md @@ -0,0 +1,9 @@ +--- +"react-router": patch +--- + +Add support for `route.unstable_lazyMiddleware` function to allow lazy loading of middleware logic. + +**Breaking change for `unstable_middleware` consumers** + +The `route.unstable_middleware` property is no longer supported in the return value from `route.lazy`. If you want to lazily load middleware, you must use `route.unstable_lazyMiddleware`. diff --git a/.changeset/strong-countries-tap.md b/.changeset/strong-countries-tap.md new file mode 100644 index 0000000000..f520e649a2 --- /dev/null +++ b/.changeset/strong-countries-tap.md @@ -0,0 +1,5 @@ +--- +"@react-router/dev": patch +--- + +Improve performance of `future.unstable_middleware` by ensuring that route modules are only blocking during the middleware phase when the `unstable_clientMiddleware` has been defined diff --git a/integration/middleware-test.ts b/integration/middleware-test.ts index 9ec8b63a68..d433ea0ac5 100644 --- a/integration/middleware-test.ts +++ b/integration/middleware-test.ts @@ -113,6 +113,97 @@ test.describe("Middleware", () => { appFixture.close(); }); + test("calls clientMiddleware before/after loaders with split route modules", async ({ + page, + }) => { + let fixture = await createFixture({ + spaMode: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + middleware: true, + splitRouteModules: true, + }), + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true, minify: false }, + plugins: [reactRouter()], + }); + `, + "app/context.ts": js` + import { unstable_createContext } from 'react-router' + export const orderContext = unstable_createContext([]); + `, + "app/routes/_index.tsx": js` + import { Link } from 'react-router' + import { orderContext } from '../context' + + export const unstable_clientMiddleware = [ + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'a']); + }, + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'b']); + }, + ]; + + export async function clientLoader({ request, context }) { + return context.get(orderContext).join(','); + } + + export default function Component({ loaderData }) { + return ( + <> +

Index: {loaderData}

+ Go to about + + ); + } + `, + "app/routes/about.tsx": js` + import { orderContext } from '../context' + + export const unstable_clientMiddleware = [ + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'c']); + }, + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'd']); + }, + ]; + + export async function clientLoader({ context }) { + return context.get(orderContext).join(','); + } + + export default function Component({ loaderData }) { + return

About: {loaderData}

; + } + `, + }, + }); + + let appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector('[data-route]:has-text("Index")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "Index: a,b" + ); + + (await page.$('a[href="/about"]'))?.click(); + await page.waitForSelector('[data-route]:has-text("About")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "About: c,d" + ); + + appFixture.close(); + }); + test("calls clientMiddleware before/after actions", async ({ page }) => { let fixture = await createFixture({ spaMode: true, @@ -596,6 +687,94 @@ test.describe("Middleware", () => { appFixture.close(); }); + test("calls clientMiddleware before/after loaders with split route modules", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "react-router.config.ts": reactRouterConfig({ + middleware: true, + splitRouteModules: true, + }), + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true, minify: false }, + plugins: [reactRouter()], + }); + `, + "app/context.ts": js` + import { unstable_createContext } from 'react-router' + export const orderContext = unstable_createContext([]); + `, + "app/routes/_index.tsx": js` + import { Link } from 'react-router' + import { orderContext } from "../context";; + + export const unstable_clientMiddleware = [ + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'a']); + }, + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'b']); + }, + ]; + + export async function clientLoader({ request, context }) { + return context.get(orderContext).join(','); + } + + export default function Component({ loaderData }) { + return ( + <> +

Index: {loaderData}

+ Go to about + + ); + } + `, + "app/routes/about.tsx": js` + import { orderContext } from "../context";; + export const unstable_clientMiddleware = [ + ({ context }) => { + context.set(orderContext, ['c']); // reset order from hydration + }, + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'd']); + }, + ]; + + export async function clientLoader({ context }) { + return context.get(orderContext).join(','); + } + + export default function Component({ loaderData }) { + return

About: {loaderData}

; + } + `, + }, + }); + + let appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector('[data-route]:has-text("Index")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "Index: a,b" + ); + + (await page.$('a[href="/about"]'))?.click(); + await page.waitForSelector('[data-route]:has-text("About")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "About: c,d" + ); + + appFixture.close(); + }); + test("calls clientMiddleware before/after actions", async ({ page }) => { let fixture = await createFixture({ files: { @@ -1074,7 +1253,7 @@ test.describe("Middleware", () => { await page.waitForSelector("[data-child]"); // 2 separate server requests made - expect(requests).toEqual([ + expect(requests.sort()).toEqual([ expect.stringContaining("/parent/child.data?_routes=routes%2Fparent"), expect.stringContaining( "/parent/child.data?_routes=routes%2Fparent.child" @@ -1236,15 +1415,15 @@ test.describe("Middleware", () => { await page.waitForSelector("[data-action]"); // 2 separate server requests made - expect(requests).toEqual([ - // index gets it's own due to clientLoader - expect.stringMatching( - /\/parent\/child\.data\?_routes=routes%2Fparent\.child\._index$/ - ), + expect(requests.sort()).toEqual([ // This is the normal request but only included parent.child because parent opted out expect.stringMatching( /\/parent\/child\.data\?_routes=routes%2Fparent\.child$/ ), + // index gets it's own due to clientLoader + expect.stringMatching( + /\/parent\/child\.data\?_routes=routes%2Fparent\.child\._index$/ + ), ]); // But client middlewares only ran once for the action and once for the revalidation diff --git a/packages/react-router-dev/manifest.ts b/packages/react-router-dev/manifest.ts index 06a1bb80b6..98e7f994b9 100644 --- a/packages/react-router-dev/manifest.ts +++ b/packages/react-router-dev/manifest.ts @@ -7,12 +7,14 @@ export type ManifestRoute = { module: string; clientLoaderModule: string | undefined; clientActionModule: string | undefined; + clientMiddlewareModule: string | undefined; hydrateFallbackModule: string | undefined; imports?: string[]; hasAction: boolean; hasLoader: boolean; hasClientAction: boolean; hasClientLoader: boolean; + hasClientMiddleware: boolean; hasErrorBoundary: boolean; }; diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 6dc4407cd5..be0dd4eec9 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -843,6 +843,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { let isRootRoute = route.parentId === undefined; let hasClientAction = sourceExports.includes("clientAction"); let hasClientLoader = sourceExports.includes("clientLoader"); + let hasClientMiddleware = sourceExports.includes( + "unstable_clientMiddleware" + ); let hasHydrateFallback = sourceExports.includes("HydrateFallback"); let { hasRouteChunkByExportName } = await detectRouteChunksIfEnabled( @@ -861,6 +864,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { !hasClientAction || hasRouteChunkByExportName.clientAction, clientLoader: !hasClientLoader || hasRouteChunkByExportName.clientLoader, + unstable_clientMiddleware: + !hasClientMiddleware || + hasRouteChunkByExportName.unstable_clientMiddleware, HydrateFallback: !hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback, }, @@ -877,6 +883,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { hasLoader: sourceExports.includes("loader"), hasClientAction, hasClientLoader, + hasClientMiddleware, hasErrorBoundary: sourceExports.includes("ErrorBoundary"), ...getReactRouterManifestBuildAssets( ctx, @@ -901,6 +908,14 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { getRouteChunkModuleId(routeFile, "clientLoader") ) : undefined, + clientMiddlewareModule: + hasRouteChunkByExportName.unstable_clientMiddleware + ? getPublicModulePathForEntry( + ctx, + viteManifest, + getRouteChunkModuleId(routeFile, "unstable_clientMiddleware") + ) + : undefined, hydrateFallbackModule: hasRouteChunkByExportName.HydrateFallback ? getPublicModulePathForEntry( ctx, @@ -971,6 +986,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { let sourceExports = routeManifestExports[key]; let hasClientAction = sourceExports.includes("clientAction"); let hasClientLoader = sourceExports.includes("clientLoader"); + let hasClientMiddleware = sourceExports.includes( + "unstable_clientMiddleware" + ); let hasHydrateFallback = sourceExports.includes("HydrateFallback"); let routeModulePath = combineURLs( ctx.publicPath, @@ -996,6 +1014,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { !hasClientAction || hasRouteChunkByExportName.clientAction, clientLoader: !hasClientLoader || hasRouteChunkByExportName.clientLoader, + unstable_clientMiddleware: + !hasClientMiddleware || + hasRouteChunkByExportName.unstable_clientMiddleware, HydrateFallback: !hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback, }, @@ -1012,11 +1033,13 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { // Split route modules are a build-time optimization clientActionModule: undefined, clientLoaderModule: undefined, + clientMiddlewareModule: undefined, hydrateFallbackModule: undefined, hasAction: sourceExports.includes("action"), hasLoader: sourceExports.includes("loader"), hasClientAction, hasClientLoader, + hasClientMiddleware, hasErrorBoundary: sourceExports.includes("ErrorBoundary"), imports: [], }; @@ -1885,6 +1908,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { valid: { clientAction: !exportNames.includes("clientAction"), clientLoader: !exportNames.includes("clientLoader"), + unstable_clientMiddleware: !exportNames.includes( + "unstable_clientMiddleware" + ), HydrateFallback: !exportNames.includes("HydrateFallback"), }, }); @@ -2220,6 +2246,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { "hasAction", "hasClientAction", "clientActionModule", + "hasClientMiddleware", + "clientMiddlewareModule", "hasErrorBoundary", "hydrateFallbackModule", ] as const @@ -2423,6 +2451,9 @@ async function getRouteMetadata( clientLoaderModule: hasRouteChunkByExportName.clientLoader ? `${getRouteChunkModuleId(moduleUrl, "clientLoader")}` : undefined, + clientMiddlewareModule: hasRouteChunkByExportName.unstable_clientMiddleware + ? `${getRouteChunkModuleId(moduleUrl, "unstable_clientMiddleware")}` + : undefined, hydrateFallbackModule: hasRouteChunkByExportName.HydrateFallback ? `${getRouteChunkModuleId(moduleUrl, "HydrateFallback")}` : undefined, @@ -2430,6 +2461,7 @@ async function getRouteMetadata( hasClientAction: sourceExports.includes("clientAction"), hasLoader: sourceExports.includes("loader"), hasClientLoader: sourceExports.includes("clientLoader"), + hasClientMiddleware: sourceExports.includes("unstable_clientMiddleware"), hasErrorBoundary: sourceExports.includes("ErrorBoundary"), imports: [], }; @@ -3076,6 +3108,7 @@ async function detectRouteChunksIfEnabled( hasRouteChunkByExportName: { clientAction: false, clientLoader: false, + unstable_clientMiddleware: false, HydrateFallback: false, }, }; @@ -3438,7 +3471,10 @@ export async function getEnvironmentOptionsResolvers( entryFileNames: ({ moduleIds }) => { let routeChunkModuleId = moduleIds.find(isRouteChunkModuleId); let routeChunkName = routeChunkModuleId - ? getRouteChunkNameFromModuleId(routeChunkModuleId) + ? getRouteChunkNameFromModuleId(routeChunkModuleId)?.replace( + "unstable_", + "" + ) : null; let routeChunkSuffix = routeChunkName ? `-${kebabCase(routeChunkName)}` diff --git a/packages/react-router-dev/vite/route-chunks.ts b/packages/react-router-dev/vite/route-chunks.ts index 39aec85e29..3c21e26c8a 100644 --- a/packages/react-router-dev/vite/route-chunks.ts +++ b/packages/react-router-dev/vite/route-chunks.ts @@ -934,6 +934,7 @@ export function detectRouteChunks( export const routeChunkExportNames = [ "clientAction", "clientLoader", + "unstable_clientMiddleware", "HydrateFallback", ] as const; export type RouteChunkExportName = (typeof routeChunkExportNames)[number]; @@ -963,6 +964,7 @@ const routeChunkQueryStrings: Record = { main: `${routeChunkQueryStringPrefix}main`, clientAction: `${routeChunkQueryStringPrefix}clientAction`, clientLoader: `${routeChunkQueryStringPrefix}clientLoader`, + unstable_clientMiddleware: `${routeChunkQueryStringPrefix}unstable_clientMiddleware`, HydrateFallback: `${routeChunkQueryStringPrefix}HydrateFallback`, }; diff --git a/packages/react-router-dev/vite/static/refresh-utils.cjs b/packages/react-router-dev/vite/static/refresh-utils.cjs index 9bdb6014a2..66243e8848 100644 --- a/packages/react-router-dev/vite/static/refresh-utils.cjs +++ b/packages/react-router-dev/vite/static/refresh-utils.cjs @@ -45,7 +45,12 @@ const enqueueUpdate = debounce(async () => { let needsRevalidation = new Set( Array.from(routeUpdates.values()) - .filter((route) => route.hasLoader || route.hasClientLoader) + .filter( + (route) => + route.hasLoader || + route.hasClientLoader || + route.hasClientMiddleware + ) .map((route) => route.id) ); diff --git a/packages/react-router/__tests__/router/context-middleware-test.ts b/packages/react-router/__tests__/router/context-middleware-test.ts index 45f9d1ff5b..c923e01a62 100644 --- a/packages/react-router/__tests__/router/context-middleware-test.ts +++ b/packages/react-router/__tests__/router/context-middleware-test.ts @@ -550,6 +550,248 @@ describe("context/middleware", () => { }); }); + describe("lazy", () => { + it("runs lazy loaded middleware", async () => { + let snapshot; + 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", + 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()", + "c middleware - before next()", + "d middleware - before next()", + "parent loader", + "child loader", + "d middleware - after next()", + "c middleware - after next()", + "b middleware - after next()", + "a middleware - after next()", + ]); + }); + + it("runs lazy loaded middleware when static middleware is defined", async () => { + let snapshot; + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "/parent", + unstable_middleware: [ + 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", + 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()", + "c middleware - before next()", + "d middleware - before next()", + "parent loader", + "child loader", + "d middleware - after next()", + "c middleware - after next()", + "b middleware - after next()", + "a middleware - after next()", + ]); + }); + + it("ignores 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_middleware: [ + 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_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", () => { it("throwing from a middleware short circuits immediately (going down - loader)", async () => { router = createRouter({ @@ -1331,6 +1573,77 @@ describe("context/middleware", () => { expect(res.headers.get("child2")).toEqual("yes"); }); + it("propagates a Response through lazy middleware when a `respond` API is passed", async () => { + let handler = createStaticHandler([ + { + path: "/", + }, + { + 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; + }, + ], + loader() { + return "PARENT"; + }, + children: [ + { + 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; + }, + ], + loader() { + return "CHILD"; + }, + }, + ], + }, + ]); + + let res = (await handler.query( + new Request("http://localhost/parent/child"), + { unstable_respond: respondWithJson } + )) as Response; + let staticContext = (await res.json()) as StaticHandlerContext; + + expect(staticContext).toMatchObject({ + location: { + pathname: "/parent/child", + }, + statusCode: 200, + loaderData: { + child: "CHILD", + parent: "PARENT", + }, + actionData: null, + errors: null, + }); + expect(res.headers.get("parent1")).toEqual("yes"); + expect(res.headers.get("parent2")).toEqual("yes"); + expect(res.headers.get("child1")).toEqual("yes"); + expect(res.headers.get("child2")).toEqual("yes"); + }); + it("propagates the response even if you call next and forget to return it", async () => { let handler = createStaticHandler([ { @@ -2176,6 +2489,67 @@ describe("context/middleware", () => { expect(res.headers.get("child2")).toEqual("yes"); }); + it("propagates a Response through lazy middleware when a `respond` API is passed", async () => { + let handler = createStaticHandler([ + { + path: "/", + }, + { + 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; + }, + ], + loader() { + return new Response("PARENT"); + }, + children: [ + { + 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; + }, + ], + loader({ context }) { + return new Response("CHILD"); + }, + }, + ], + }, + ]); + + let res = (await handler.queryRoute( + new Request("http://localhost/parent/child"), + { + unstable_respond: (v) => v, + } + )) as Response; + + expect(await res.text()).toBe("CHILD"); + expect(res.headers.get("parent1")).toEqual("yes"); + expect(res.headers.get("parent2")).toEqual("yes"); + expect(res.headers.get("child1")).toEqual("yes"); + expect(res.headers.get("child2")).toEqual("yes"); + }); + describe("ordering", () => { it("runs middleware sequentially before and after loaders", async () => { let handler = createStaticHandler([ diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index 46f2000308..cd01a0f166 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -27,6 +27,7 @@ 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"]; @@ -48,6 +49,7 @@ 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"]; diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index bb2cad8aef..95b07b3546 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -688,6 +688,7 @@ ${matches let { clientActionModule, clientLoaderModule, + clientMiddlewareModule, hydrateFallbackModule, module, } = manifestEntry; @@ -709,6 +710,14 @@ ${matches }, ] : []), + ...(clientMiddlewareModule + ? [ + { + module: clientMiddlewareModule, + varName: `${routeVarName}_clientMiddleware`, + }, + ] + : []), ...(hydrateFallbackModule ? [ { diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index 1babdd989b..872cd168ad 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -172,16 +172,18 @@ function processRoutes( parentId, hasAction: route.action != null, hasLoader: route.loader != null, - // When testing routes, you should just be stubbing loader/action, not - // trying to re-implement the full loader/clientLoader/SSR/hydration flow. - // That is better tested via E2E tests. + // When testing routes, you should be stubbing loader/action/middleware, + // not trying to re-implement the full loader/clientLoader/SSR/hydration + // flow. That is better tested via E2E tests. hasClientAction: false, hasClientLoader: false, + hasClientMiddleware: false, hasErrorBoundary: route.ErrorBoundary != null, // any need for these? module: "build/stub-path-to-module.js", clientActionModule: undefined, clientLoaderModule: undefined, + clientMiddlewareModule: undefined, hydrateFallbackModule: undefined, }; manifest.routes[newRoute.id] = entryRoute; diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index ccf7ea4059..16dc90f9df 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -33,12 +33,14 @@ export interface EntryRoute extends Route { hasLoader: boolean; hasClientAction: boolean; hasClientLoader: boolean; + hasClientMiddleware: boolean; hasErrorBoundary: boolean; imports?: string[]; css?: string[]; module: string; clientActionModule: string | undefined; clientLoaderModule: string | undefined; + clientMiddlewareModule: string | undefined; hydrateFallbackModule: string | undefined; parentId?: string; } @@ -471,6 +473,22 @@ export function createClientRoutes( }; } + 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) { @@ -524,9 +542,7 @@ export function createClientRoutes( return { ...(lazyRoute.loader ? { loader: lazyRoute.loader } : {}), ...(lazyRoute.action ? { action: lazyRoute.action } : {}), - unstable_middleware: mod.unstable_clientMiddleware as unknown as - | unstable_MiddlewareFunction[] - | undefined, + hasErrorBoundary: lazyRoute.hasErrorBoundary, shouldRevalidate: getShouldRevalidateFunction( lazyRoute, diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 5e44db4d38..274af83224 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -2793,8 +2793,7 @@ export function createRouter(init: RouterInit): Router { fetcherKey, manifest, mapRouteProperties, - scopedContext, - future.unstable_middleware + scopedContext ); } catch (e) { // If the outer dataStrategy method throws, just return the error for all @@ -3482,13 +3481,19 @@ export function createStaticHandler( return respond ? respond(staticContext) : staticContext; } - if (respond && matches.some((m) => m.route.unstable_middleware)) { + if ( + respond && + matches.some( + (m) => m.route.unstable_middleware || m.route.unstable_lazyMiddleware + ) + ) { invariant( requestContext instanceof unstable_RouterContextProvider, "When using middleware in `staticHandler.query()`, any provided " + "`requestContext` must be an instance of `unstable_RouterContextProvider`" ); try { + await loadLazyMiddlewareForMatches(matches, manifest); let renderedStaticContext: StaticHandlerContext | undefined; let response = await runMiddlewarePipeline( { @@ -3677,12 +3682,18 @@ export function createStaticHandler( throw getInternalRouterError(404, { pathname: location.pathname }); } - if (respond && matches.some((m) => m.route.unstable_middleware)) { + if ( + respond && + matches.some( + (m) => m.route.unstable_middleware || m.route.unstable_lazyMiddleware + ) + ) { invariant( requestContext instanceof unstable_RouterContextProvider, "When using middleware in `staticHandler.queryRoute()`, any provided " + "`requestContext` must be an instance of `unstable_RouterContextProvider`" ); + await loadLazyMiddlewareForMatches(matches, manifest); let response = await runMiddlewarePipeline( { request, @@ -4150,8 +4161,7 @@ export function createStaticHandler( null, manifest, mapRouteProperties, - requestContext, - false // middleware not done via dataStrategy in the static handler + requestContext ); let dataResults: Record = {}; @@ -4883,6 +4893,13 @@ async function loadLazyRouteModule( `The lazy route property "${lazyRouteProperty}" will be ignored.` ); + warning( + !immutableRouteKeys.has(lazyRouteProperty as ImmutableRouteKey), + "Route property " + + lazyRouteProperty + + " is not a supported property to be returned from a lazy route function. This property will be ignored." + ); + if ( !isPropertyStaticallyDefined && !immutableRouteKeys.has(lazyRouteProperty as ImmutableRouteKey) @@ -4908,6 +4925,56 @@ async function loadLazyRouteModule( }); } +async function loadLazyMiddleware( + route: AgnosticDataRouteObject, + manifest: RouteManifest +) { + 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 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) { + return; + } + + if (!routeToUpdate.unstable_middleware) { + routeToUpdate.unstable_middleware = middleware; + } + } + + routeToUpdate.unstable_lazyMiddleware = undefined; +} + +function loadLazyMiddlewareForMatches( + matches: AgnosticDataRouteMatch[], + manifest: RouteManifest +): Promise | void { + let promises = matches + .map((m) => + m.route.unstable_lazyMiddleware + ? loadLazyMiddleware(m.route, manifest) + : undefined + ) + .filter(Boolean); + + return promises.length > 0 ? Promise.all(promises) : undefined; +} + // Default implementation of `dataStrategy` which fetches all loaders in parallel async function defaultDataStrategy( args: DataStrategyFunctionArgs @@ -5087,22 +5154,20 @@ async function callDataStrategyImpl( fetcherKey: string | null, manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, - scopedContext: unknown, - enableMiddleware: boolean + scopedContext: unknown ): 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) => m.route.lazy ? loadLazyRouteModule(m.route, mapRouteProperties, manifest) : undefined ); - if (enableMiddleware) { - // TODO: For the initial implementation, we await route.lazy here to ensure - // client side middleware implementations have been loaded prior to running - // dataStrategy which will then run them. This is a de-optimization and - // will be fixed before stable release by adding a new async middleware API - // allowing us to load middleware sin a split route module. - await Promise.all(loadRouteDefinitionsPromises); + // Ensure all middleware is loaded before we start executing routes + if (loadMiddlewarePromise) { + await loadMiddlewarePromise; } let dsMatches = matches.map((match, i) => { diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index cdaf8cb959..e67c4a05a8 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -398,6 +398,8 @@ export type ImmutableRouteKey = | "path" | "id" | "index" + | "unstable_middleware" + | "unstable_lazyMiddleware" | "children"; export const immutableRouteKeys = new Set([ @@ -406,6 +408,8 @@ export const immutableRouteKeys = new Set([ "path", "id", "index", + "unstable_middleware", + "unstable_lazyMiddleware", "children", ]); @@ -424,6 +428,10 @@ export interface LazyRouteFunction { (): Promise>>; } +interface LazyMiddlewareFunction { + (): Promise; +} + /** * Base RouteObject with common props shared by all types of routes */ @@ -432,6 +440,7 @@ type AgnosticBaseRouteObject = { path?: string; id?: string; unstable_middleware?: unstable_MiddlewareFunction[]; + unstable_lazyMiddleware?: LazyMiddlewareFunction; loader?: LoaderFunction | boolean; action?: ActionFunction | boolean; hasErrorBoundary?: boolean; diff --git a/playground/middleware/dev-server.ts b/playground/middleware/dev-server.ts index 3f6c3e0233..4ddd548ea3 100644 --- a/playground/middleware/dev-server.ts +++ b/playground/middleware/dev-server.ts @@ -8,6 +8,7 @@ console.log("Starting development server"); const viteDevServer = await import("vite").then((vite) => vite.createServer({ server: { middlewareMode: true }, + forceOptimizeDeps: process.argv.includes("--force"), }) ); app.use(viteDevServer.middlewares); diff --git a/playground/middleware/react-router.config.ts b/playground/middleware/react-router.config.ts index cefa00d048..2cb7be3730 100644 --- a/playground/middleware/react-router.config.ts +++ b/playground/middleware/react-router.config.ts @@ -10,5 +10,6 @@ declare module "react-router" { export default { future: { unstable_middleware: true, + unstable_splitRouteModules: true, }, } satisfies Config;