Skip to content

Commit

Permalink
fix(vite): fix dev FOUC with custom Express server (#7937)
Browse files Browse the repository at this point in the history
  • Loading branch information
markdalgleish authored Nov 8, 2023
1 parent 8543efe commit 09d557a
Show file tree
Hide file tree
Showing 4 changed files with 322 additions and 13 deletions.
6 changes: 6 additions & 0 deletions .changeset/slow-eyes-joke.md
Original file line number Diff line number Diff line change
@@ -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
251 changes: 251 additions & 0 deletions integration/vite-css-dev-express-test.ts
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<div id="content">
<Outlet />
</div>
<Scripts />
<LiveReload />
</body>
</html>
);
}
`,
"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 (
<div id="index">
<div data-css-modules className={cssModulesStyles.index}>
<div data-css-linked className="index_linked">
<div data-css-bundled className="index_bundled">
<h2>CSS test</h2>
</div>
</div>
</div>
</div>
);
}
`,
},
});

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
);
});
});
});
69 changes: 57 additions & 12 deletions packages/remix-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -596,22 +604,59 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
serverEntryId
) as Promise<ServerBuild>);

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<string, any>).__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() {
Expand Down
9 changes: 8 additions & 1 deletion packages/remix-express/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit 09d557a

Please sign in to comment.