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 ( +
+
+
+
+

CSS test

+
+
+
+
+ ); + } + `, + }, + }); + + 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) {