Skip to content

Add client middleware to split route modules #13210

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/large-shoes-live.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .changeset/silent-apples-return.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .changeset/strong-countries-tap.md
Original file line number Diff line number Diff line change
@@ -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
191 changes: 185 additions & 6 deletions integration/middleware-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<h2 data-route>Index: {loaderData}</h2>
<Link to="/about">Go to about</Link>
</>
);
}
`,
"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 <h2 data-route>About: {loaderData}</h2>;
}
`,
},
});

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,
Expand Down Expand Up @@ -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 (
<>
<h2 data-route>Index: {loaderData}</h2>
<Link to="/about">Go to about</Link>
</>
);
}
`,
"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 <h2 data-route>About: {loaderData}</h2>;
}
`,
},
});

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: {
Expand Down Expand Up @@ -1074,7 +1253,7 @@ test.describe("Middleware", () => {
await page.waitForSelector("[data-child]");

// 2 separate server requests made
expect(requests).toEqual([
expect(requests.sort()).toEqual([
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was flaky in Firefox due to the order of requests not being deterministic.

expect.stringContaining("/parent/child.data?_routes=routes%2Fparent"),
expect.stringContaining(
"/parent/child.data?_routes=routes%2Fparent.child"
Expand Down Expand Up @@ -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([
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

// 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
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router-dev/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
38 changes: 37 additions & 1 deletion packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
},
Expand All @@ -877,6 +883,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
hasLoader: sourceExports.includes("loader"),
hasClientAction,
hasClientLoader,
hasClientMiddleware,
hasErrorBoundary: sourceExports.includes("ErrorBoundary"),
...getReactRouterManifestBuildAssets(
ctx,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
},
Expand All @@ -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: [],
};
Expand Down Expand Up @@ -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"),
},
});
Expand Down Expand Up @@ -2220,6 +2246,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
"hasAction",
"hasClientAction",
"clientActionModule",
"hasClientMiddleware",
"clientMiddlewareModule",
"hasErrorBoundary",
"hydrateFallbackModule",
] as const
Expand Down Expand Up @@ -2423,13 +2451,17 @@ 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,
hasAction: sourceExports.includes("action"),
hasClientAction: sourceExports.includes("clientAction"),
hasLoader: sourceExports.includes("loader"),
hasClientLoader: sourceExports.includes("clientLoader"),
hasClientMiddleware: sourceExports.includes("unstable_clientMiddleware"),
hasErrorBoundary: sourceExports.includes("ErrorBoundary"),
imports: [],
};
Expand Down Expand Up @@ -3076,6 +3108,7 @@ async function detectRouteChunksIfEnabled(
hasRouteChunkByExportName: {
clientAction: false,
clientLoader: false,
unstable_clientMiddleware: false,
HydrateFallback: false,
},
};
Expand Down Expand Up @@ -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)}`
Expand Down
Loading