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;