diff --git a/.changeset/slow-eyes-joke.md b/.changeset/slow-eyes-joke.md
new file mode 100644
index 00000000000..fa967b63f09
--- /dev/null
+++ b/.changeset/slow-eyes-joke.md
@@ -0,0 +1,6 @@
+---
+"@remix-run/dev": patch
+"@remix-run/express": patch
+---
+
+Fix flash of unstyled content on initial page load in Vite dev when using a custom Express server
diff --git a/integration/vite-css-dev-express-test.ts b/integration/vite-css-dev-express-test.ts
new file mode 100644
index 00000000000..c1a2886ceff
--- /dev/null
+++ b/integration/vite-css-dev-express-test.ts
@@ -0,0 +1,251 @@
+import { test, expect } from "@playwright/test";
+import fs from "node:fs/promises";
+import path from "node:path";
+import getPort from "get-port";
+
+import { createFixtureProject, js, css } from "./helpers/create-fixture.js";
+import { kill, node } from "./helpers/dev.js";
+
+const TEST_PADDING_VALUE = "20px";
+const UPDATED_TEST_PADDING_VALUE = "30px";
+
+test.describe("Vite CSS dev (Express server)", () => {
+ let projectDir: string;
+ let dev: { pid: number; port: number };
+
+ test.beforeAll(async () => {
+ let port = await getPort();
+ let hmrPort = await getPort();
+ projectDir = await createFixtureProject({
+ compiler: "vite",
+ files: {
+ "remix.config.js": js`
+ throw new Error("Remix should not access remix.config.js when using Vite");
+ export default {};
+ `,
+ "vite.config.ts": js`
+ import { defineConfig } from "vite";
+ import { unstable_vitePlugin as remix } from "@remix-run/dev";
+
+ export default defineConfig({
+ server: {
+ hmr: {
+ port: ${hmrPort}
+ }
+ },
+ plugins: [remix()],
+ });
+ `,
+ "server.mjs": js`
+ import {
+ unstable_createViteServer,
+ unstable_loadViteServerBuild,
+ } from "@remix-run/dev";
+ import { createRequestHandler } from "@remix-run/express";
+ import { installGlobals } from "@remix-run/node";
+ import express from "express";
+
+ installGlobals();
+
+ let vite =
+ process.env.NODE_ENV === "production"
+ ? undefined
+ : await unstable_createViteServer();
+
+ const app = express();
+
+ if (vite) {
+ app.use(vite.middlewares);
+ } else {
+ app.use(
+ "/build",
+ express.static("public/build", { immutable: true, maxAge: "1y" })
+ );
+ }
+ app.use(express.static("public", { maxAge: "1h" }));
+
+ app.all(
+ "*",
+ createRequestHandler({
+ build: vite
+ ? () => unstable_loadViteServerBuild(vite)
+ : await import("./build/index.js"),
+ })
+ );
+
+ const port = ${port};
+ app.listen(port, () => console.log('http://localhost:' + port));
+ `,
+ "app/root.tsx": js`
+ import { Links, Meta, Outlet, Scripts, LiveReload } from "@remix-run/react";
+
+ export default function Root() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ `,
+ "app/styles-bundled.css": css`
+ .index_bundled {
+ background: papayawhip;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/styles-linked.css": css`
+ .index_linked {
+ background: salmon;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/styles.module.css": css`
+ .index {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/routes/_index.tsx": js`
+ import "../styles-bundled.css";
+ import linkedStyles from "../styles-linked.css?url";
+ import cssModulesStyles from "../styles.module.css";
+
+ export function links() {
+ return [{ rel: "stylesheet", href: linkedStyles }];
+ }
+
+ export default function IndexRoute() {
+ return (
+
+ );
+ }
+ `,
+ },
+ });
+
+ dev = await node(projectDir, ["./server.mjs"], { port });
+ });
+
+ test.afterAll(async () => {
+ await kill(dev.pid);
+ });
+
+ test.describe("without JS", () => {
+ test.use({ javaScriptEnabled: false });
+ test("renders CSS", async ({ page }) => {
+ await page.goto(`http://localhost:${dev.port}/`, {
+ waitUntil: "networkidle",
+ });
+ await expect(page.locator("#index [data-css-modules]")).toHaveCSS(
+ "padding",
+ TEST_PADDING_VALUE
+ );
+ await expect(page.locator("#index [data-css-linked]")).toHaveCSS(
+ "padding",
+ TEST_PADDING_VALUE
+ );
+ await expect(page.locator("#index [data-css-bundled]")).toHaveCSS(
+ "padding",
+ TEST_PADDING_VALUE
+ );
+ });
+ });
+
+ test.describe("with JS", () => {
+ test.use({ javaScriptEnabled: true });
+ test("updates CSS", async ({ page }) => {
+ let pageErrors: unknown[] = [];
+ page.on("pageerror", (error) => pageErrors.push(error));
+
+ await page.goto(`http://localhost:${dev.port}/`, {
+ waitUntil: "networkidle",
+ });
+
+ // Ensure no errors on page load
+ expect(pageErrors).toEqual([]);
+
+ await expect(page.locator("#index [data-css-modules]")).toHaveCSS(
+ "padding",
+ TEST_PADDING_VALUE
+ );
+ await expect(page.locator("#index [data-css-linked]")).toHaveCSS(
+ "padding",
+ TEST_PADDING_VALUE
+ );
+ await expect(page.locator("#index [data-css-bundled]")).toHaveCSS(
+ "padding",
+ TEST_PADDING_VALUE
+ );
+
+ let bundledCssContents = await fs.readFile(
+ path.join(projectDir, "app/styles-bundled.css"),
+ "utf8"
+ );
+ await fs.writeFile(
+ path.join(projectDir, "app/styles-bundled.css"),
+ bundledCssContents.replace(
+ TEST_PADDING_VALUE,
+ UPDATED_TEST_PADDING_VALUE
+ ),
+ "utf8"
+ );
+
+ let linkedCssContents = await fs.readFile(
+ path.join(projectDir, "app/styles-linked.css"),
+ "utf8"
+ );
+ await fs.writeFile(
+ path.join(projectDir, "app/styles-linked.css"),
+ linkedCssContents.replace(
+ TEST_PADDING_VALUE,
+ UPDATED_TEST_PADDING_VALUE
+ ),
+ "utf8"
+ );
+
+ let cssModuleContents = await fs.readFile(
+ path.join(projectDir, "app/styles.module.css"),
+ "utf8"
+ );
+ await fs.writeFile(
+ path.join(projectDir, "app/styles.module.css"),
+ cssModuleContents.replace(
+ TEST_PADDING_VALUE,
+ UPDATED_TEST_PADDING_VALUE
+ ),
+ "utf8"
+ );
+
+ await expect(page.locator("#index [data-css-modules]")).toHaveCSS(
+ "padding",
+ UPDATED_TEST_PADDING_VALUE
+ );
+ await expect(page.locator("#index [data-css-linked]")).toHaveCSS(
+ "padding",
+ UPDATED_TEST_PADDING_VALUE
+ );
+ await expect(page.locator("#index [data-css-bundled]")).toHaveCSS(
+ "padding",
+ UPDATED_TEST_PADDING_VALUE
+ );
+ });
+ });
+});
diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts
index 3f75e30b730..e26661e26e9 100644
--- a/packages/remix-dev/vite/plugin.ts
+++ b/packages/remix-dev/vite/plugin.ts
@@ -4,6 +4,7 @@ import * as fs from "node:fs/promises";
import babel from "@babel/core";
import { type ServerBuild } from "@remix-run/server-runtime";
import {
+ type Connect,
type Plugin as VitePlugin,
type Manifest as ViteManifest,
type ResolvedConfig as ResolvedViteConfig,
@@ -27,6 +28,7 @@ import {
resolveConfig,
} from "../config";
import { type Manifest } from "../manifest";
+import invariant from "../invariant";
import { createRequestHandler } from "./node/adapter";
import { getStylesForUrl, isCssModulesFile } from "./styles";
import * as VirtualModule from "./vmod";
@@ -567,8 +569,14 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
// have no way of comparing against the cache to know if the virtual modules need to be invalidated.
let previousPluginConfig: ResolvedRemixVitePluginConfig | undefined;
- // Let user servers handle SSR requests in middleware mode
- if (vite.config.server.middlewareMode) return;
+ let localsByRequest = new WeakMap<
+ Connect.IncomingMessage,
+ {
+ build: ServerBuild;
+ criticalCss: string | undefined;
+ }
+ >();
+
return () => {
vite.middlewares.use(async (req, res, next) => {
try {
@@ -596,22 +604,59 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
serverEntryId
) as Promise);
- let handle = createRequestHandler(build, {
- mode: "development",
- criticalCss: await getStylesForUrl(
- vite,
- pluginConfig,
- cssModulesManifest,
- build,
- url
- ),
+ let criticalCss = await getStylesForUrl(
+ vite,
+ pluginConfig,
+ cssModulesManifest,
+ build,
+ url
+ );
+
+ localsByRequest.set(req, {
+ build,
+ criticalCss,
});
- await handle(req, res);
+ // If the middleware is being used in Express, the "res.locals"
+ // object (https://expressjs.com/en/api.html#res.locals) will be
+ // present. If so, we attach the critical CSS as metadata to the
+ // response object so the Remix Express adapter has access to it.
+ if (
+ "locals" in res &&
+ typeof res.locals === "object" &&
+ res.locals !== null
+ ) {
+ (res.locals as Record).__remixDevCriticalCss =
+ criticalCss;
+ }
+
+ next();
} catch (error) {
next(error);
}
});
+
+ // Let user servers handle SSR requests in middleware mode,
+ // otherwise the Vite plugin will handle the request
+ if (!vite.config.server.middlewareMode) {
+ vite.middlewares.use(async (req, res, next) => {
+ try {
+ let locals = localsByRequest.get(req);
+ invariant(locals, "No Remix locals found for request");
+
+ let { build, criticalCss } = locals;
+
+ let handle = createRequestHandler(build, {
+ mode: "development",
+ criticalCss,
+ });
+
+ await handle(req, res);
+ } catch (error) {
+ next(error);
+ }
+ });
+ }
};
},
async buildEnd() {
diff --git a/packages/remix-express/server.ts b/packages/remix-express/server.ts
index 016cdfb1d9b..161d5af0884 100644
--- a/packages/remix-express/server.ts
+++ b/packages/remix-express/server.ts
@@ -52,7 +52,14 @@ export function createRequestHandler({
let request = createRemixRequest(req, res);
let loadContext = await getLoadContext?.(req, res);
- let response = await handleRequest(request, loadContext);
+ let criticalCss =
+ mode === "production" ? null : res.locals.__remixDevCriticalCss;
+
+ let response = await handleRequest(
+ request,
+ loadContext,
+ criticalCss ? { __criticalCss: criticalCss } : undefined
+ );
await sendRemixResponse(res, response);
} catch (error: unknown) {