Skip to content

Support flexible ordering of Vite plugins that override SSR environment #13183

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 9 commits into from
Mar 13, 2025
Merged
5 changes: 5 additions & 0 deletions .changeset/dull-hotels-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-router/dev": patch
---

When `future.unstable_viteEnvironmentApi` is enabled, allow plugins that override the default SSR environment (such as `@cloudflare/vite-plugin`) to be placed before or after the React Router plugin.
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"serialize-javascript": "^6.0.1"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^0.1.1",
"@cloudflare/vite-plugin": "^0.1.9",
"@cloudflare/workers-types": "^4.20250214.0",
"@react-router/dev": "workspace:*",
"@react-router/fs-routes": "workspace:*",
Expand Down
97 changes: 70 additions & 27 deletions integration/vite-plugin-cloudflare-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,70 @@ import { type Files, test, viteConfig } from "./helpers/vite.js";
const tsx = dedent;
const css = dedent;

test.describe("vite-plugin-cloudflare", () => {
function defineFiles({
reversePlugins = false,
}: { reversePlugins?: boolean } = {}): Files {
const files: Files = async ({ port }) => ({
"vite.config.ts": tsx`
import { defineConfig } from "vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { reactRouter } from "@react-router/dev/vite";

export default defineConfig({
${await viteConfig.server({ port })}
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
reactRouter(),
],
});
`,
import { defineConfig } from "vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { reactRouter } from "@react-router/dev/vite";

export default defineConfig({
${await viteConfig.server({ port })}
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
reactRouter(),
]${reversePlugins ? ".reverse()" : ""},
});
`,
"app/routes/env.tsx": tsx`
import type { Route } from "./+types/env";
export function loader({ context }: Route.LoaderArgs) {
return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE };
}
export default function EnvRoute({ loaderData }: Route.RouteComponentProps) {
return <div data-loader-message>{loaderData.message}</div>;
}
`,
import type { Route } from "./+types/env";
export function loader({ context }: Route.LoaderArgs) {
return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE };
}
export default function EnvRoute({ loaderData }: Route.RouteComponentProps) {
return <div data-loader-message>{loaderData.message}</div>;
}
`,
"app/routes/css-side-effect/route.tsx": tsx`
import "./styles.css";
export default function CssSideEffectRoute() {
return <div className="css-side-effect" data-css-side-effect>CSS Side Effect</div>;
}
`,
import "./styles.css";

export default function CssSideEffectRoute() {
return <div className="css-side-effect" data-css-side-effect>CSS Side Effect</div>;
}
`,
"app/routes/css-side-effect/styles.css": css`
.css-side-effect {
padding: 20px;
}
`,
});
return files;
}

test.describe("vite-plugin-cloudflare", () => {
test("handles Cloudflare env", async ({ dev, page }) => {
const files = defineFiles();
const { port } = await dev(files, "vite-plugin-cloudflare-template");

await page.goto(`http://localhost:${port}/env`, {
waitUntil: "networkidle",
});

// Ensure no errors on page load
expect(page.errors).toEqual([]);

await expect(page.locator("[data-loader-message]")).toHaveText(
"Hello from Cloudflare"
);
});

test("handles Cloudflare env with plugin order reversed", async ({
dev,
page,
}) => {
const files = defineFiles({ reversePlugins: true });
const { port } = await dev(files, "vite-plugin-cloudflare-template");

await page.goto(`http://localhost:${port}/env`, {
Expand All @@ -66,6 +91,7 @@ test.describe("vite-plugin-cloudflare", () => {
dev,
page,
}) => {
const files = defineFiles();
const { port } = await dev(files, "vite-plugin-cloudflare-template");

await page.goto(`http://localhost:${port}/css-side-effect`, {
Expand All @@ -78,4 +104,21 @@ test.describe("vite-plugin-cloudflare", () => {
);
});
});

test("handles CSS side effects during SSR in dev with plugin order reversed", async ({
dev,
page,
}) => {
const files = defineFiles({ reversePlugins: true });
const { port } = await dev(files, "vite-plugin-cloudflare-template");

await page.goto(`http://localhost:${port}/css-side-effect`, {
waitUntil: "networkidle",
});

await expect(page.locator("[data-css-side-effect]")).toHaveCSS(
"padding",
"20px"
);
});
});
86 changes: 49 additions & 37 deletions packages/react-router-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,7 @@ const CSS_DEV_HELPER_ENVIRONMENT_NAME =
"__react_router_css_dev_helper__" as const;
type CssDevHelperEnvironmentName = typeof CSS_DEV_HELPER_ENVIRONMENT_NAME;

type EnvironmentOptions = Pick<
Vite.EnvironmentOptions,
"build" | "resolve" | "optimizeDeps"
>;
type EnvironmentOptions = Pick<Vite.EnvironmentOptions, "build" | "resolve">;

type EnvironmentOptionsResolver = (options: {
viteUserConfig: Vite.UserConfig;
Expand All @@ -178,19 +175,13 @@ export type EnvironmentBuildContext = {
resolveOptions: EnvironmentOptionsResolver;
};

function isSeverBundleEnvironmentName(
name: string
): name is SsrEnvironmentName {
return name.startsWith(SSR_BUNDLE_PREFIX);
}

function getServerEnvironmentEntries<T>(
ctx: ReactRouterPluginContext,
record: Record<string, T>
): [SsrEnvironmentName, T][] {
return Object.entries(record).filter(([name]) =>
ctx.buildManifest?.serverBundles
? isSeverBundleEnvironmentName(name)
? isSsrBundleEnvironmentName(name)
: name === "ssr"
) as [SsrEnvironmentName, T][];
}
Expand Down Expand Up @@ -1295,6 +1286,50 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
}),
};
},
configEnvironment(name, options) {
if (
ctx.reactRouterConfig.future.unstable_viteEnvironmentApi &&
(ctx.buildManifest?.serverBundles
? isSsrBundleEnvironmentName(name)
: name === "ssr")
) {
const vite = getVite();

return {
resolve: {
external:
// This check is required to honor the "noExternal: true" config
// provided by vite-plugin-cloudflare within this repo. When compiling
// for Cloudflare, all server dependencies are pre-bundled, but our
// `ssrExternals` config inadvertently overrides this. This doesn't
// impact consumers because for them `ssrExternals` is undefined and
// Cloudflare's "noExternal: true" config remains intact.
options.resolve?.noExternal === true ? undefined : ssrExternals,
},
optimizeDeps:
options.optimizeDeps?.noDiscovery === false
? {
entries: [
vite.normalizePath(ctx.entryServerFilePath),
...Object.values(ctx.reactRouterConfig.routes).map(
(route) =>
resolveRelativeRouteFilePath(
route,
ctx.reactRouterConfig
)
),
],
include: [
"react",
"react/jsx-dev-runtime",
"react-dom/server",
"react-router",
],
}
: undefined,
};
}
},
async configResolved(resolvedViteConfig) {
await initEsModuleLexer;

Expand Down Expand Up @@ -1531,6 +1566,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => {
let vite = getVite();
let ssrEnvironment = viteDevServer.environments.ssr;
if (!vite.isRunnableDevEnvironment(ssrEnvironment)) {
next();
return;
}
build = (await ssrEnvironment.runner.import(
Expand Down Expand Up @@ -3315,14 +3351,8 @@ export async function getEnvironmentOptionsResolvers(
return mergeEnvironmentOptions(getBaseOptions({ viteUserConfig }), {
resolve: {
external:
// This check is required to honor the "noExternal: true" config
// provided by vite-plugin-cloudflare within this repo. When compiling
// for Cloudflare, all server dependencies are externalized, but our
// `ssrExternals` config inadvertently overrides this. This doesn't
// impact consumers because for them `ssrExternals` is undefined and
// Cloudflare's "noExternal: true" config remains intact.
ctx.reactRouterConfig.future.unstable_viteEnvironmentApi &&
viteUserConfig.environments?.ssr?.resolve?.noExternal === true
// If `unstable_viteEnvironmentApi` is `true`, `resolve.external` is set in the `configEnvironment` hook
ctx.reactRouterConfig.future.unstable_viteEnvironmentApi
? undefined
: ssrExternals,
conditions,
Expand Down Expand Up @@ -3435,24 +3465,6 @@ export async function getEnvironmentOptionsResolvers(
build: {
outDir: getServerBuildDirectory(ctx.reactRouterConfig),
},
optimizeDeps:
ctx.reactRouterConfig.future.unstable_viteEnvironmentApi &&
viteUserConfig.environments?.ssr?.optimizeDeps?.noDiscovery === false
? {
entries: [
vite.normalizePath(ctx.entryServerFilePath),
...Object.values(ctx.reactRouterConfig.routes).map((route) =>
resolveRelativeRouteFilePath(route, ctx.reactRouterConfig)
),
],
include: [
"react",
"react/jsx-dev-runtime",
"react-dom/server",
"react-router",
],
}
: undefined,
});
}

Expand Down
Loading
Loading