Skip to content
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

fix(vite): fix dev FOUC with custom Express server #7937

Merged
merged 1 commit into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
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