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 2 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
---

Split `unstable_clientMiddleware` route export when `future.unstable_splitRouteModules` is enabled
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`.
179 changes: 179 additions & 0 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
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 @@ -852,6 +852,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 @@ -870,6 +873,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
!hasClientAction || hasRouteChunkByExportName.clientAction,
clientLoader:
!hasClientLoader || hasRouteChunkByExportName.clientLoader,
unstable_clientMiddleware:
!hasClientMiddleware ||
hasRouteChunkByExportName.unstable_clientMiddleware,
HydrateFallback:
!hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback,
},
Expand All @@ -886,6 +892,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
hasLoader: sourceExports.includes("loader"),
hasClientAction,
hasClientLoader,
hasClientMiddleware,
hasErrorBoundary: sourceExports.includes("ErrorBoundary"),
...getReactRouterManifestBuildAssets(
ctx,
Expand All @@ -910,6 +917,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 @@ -980,6 +995,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 @@ -1005,6 +1023,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
!hasClientAction || hasRouteChunkByExportName.clientAction,
clientLoader:
!hasClientLoader || hasRouteChunkByExportName.clientLoader,
unstable_clientMiddleware:
!hasClientMiddleware ||
hasRouteChunkByExportName.unstable_clientMiddleware,
HydrateFallback:
!hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback,
},
Expand All @@ -1021,11 +1042,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 @@ -1840,6 +1863,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 @@ -2175,6 +2201,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
"hasAction",
"hasClientAction",
"clientActionModule",
"hasClientMiddleware",
"clientMiddlewareModule",
"hasErrorBoundary",
"hydrateFallbackModule",
] as const
Expand Down Expand Up @@ -2378,13 +2406,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 @@ -3024,6 +3056,7 @@ async function detectRouteChunksIfEnabled(
hasRouteChunkByExportName: {
clientAction: false,
clientLoader: false,
unstable_clientMiddleware: false,
HydrateFallback: false,
},
};
Expand Down Expand Up @@ -3391,7 +3424,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
2 changes: 2 additions & 0 deletions packages/react-router-dev/vite/route-chunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,7 @@ export function detectRouteChunks(
export const routeChunkExportNames = [
"clientAction",
"clientLoader",
"unstable_clientMiddleware",
"HydrateFallback",
] as const;
export type RouteChunkExportName = (typeof routeChunkExportNames)[number];
Expand Down Expand Up @@ -963,6 +964,7 @@ const routeChunkQueryStrings: Record<RouteChunkName, RouteChunkQueryString> = {
main: `${routeChunkQueryStringPrefix}main`,
clientAction: `${routeChunkQueryStringPrefix}clientAction`,
clientLoader: `${routeChunkQueryStringPrefix}clientLoader`,
unstable_clientMiddleware: `${routeChunkQueryStringPrefix}unstable_clientMiddleware`,
HydrateFallback: `${routeChunkQueryStringPrefix}HydrateFallback`,
};

Expand Down
7 changes: 6 additions & 1 deletion packages/react-router-dev/vite/static/refresh-utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);

Expand Down
Loading
Loading