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(remix-dev/vite): use ssrEmitAssets to support assets referenced by server-only code #7892

Merged
merged 10 commits into from
Nov 16, 2023
5 changes: 5 additions & 0 deletions .changeset/tricky-frogs-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/dev": patch
---

Emit assets that were only referenced in the server build into the client assets directory in Vite build
42 changes: 42 additions & 0 deletions integration/vite-build-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ test.describe("Vite build", () => {
import mdx from "@mdx-js/rollup";

export default defineConfig({
build: {
// force emitting asset files instead of inlined as data-url
assetsInlineLimit: 0,
},
plugins: [
remix(),
mdx(),
Expand Down Expand Up @@ -183,6 +187,29 @@ test.describe("Vite build", () => {
return <div data-dotenv-route-loader-content>{loaderContent}</div>;
}
`,

"app/routes/ssr-assets.tsx": js`
import url1 from "../assets/test1.txt?url";
import url2 from "../assets/test2.txt?url";
import { useLoaderData } from "@remix-run/react"

export const loader: LoaderFunction = () => {
return { url2 };
};

export default function SsrAssetRoute() {
const loaderData = useLoaderData();
return (
<div>
<a href={url1}>url1</a>
<a href={loaderData.url2}>url2</a>
</div>
);
}
`,

"app/assets/test1.txt": "test1",
"app/assets/test2.txt": "test2",
},
});

Expand Down Expand Up @@ -252,6 +279,21 @@ test.describe("Vite build", () => {
expect(pageErrors).toEqual([]);
});

test("emits SSR assets to the client assets directory", async ({ page }) => {
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/ssr-assets");

// verify asset files are emitted and served correctly
await page.getByRole("link", { name: "url1" }).click();
await page.waitForURL("**/build/assets/test1-*.txt");
await page.getByText("test1").click();
await page.goBack();

await page.getByRole("link", { name: "url2" }).click();
await page.waitForURL("**/build/assets/test2-*.txt");
await page.getByText("test2").click();
});

test("supports code-split css", async ({ page }) => {
let pageErrors: unknown[] = [];
page.on("pageerror", (error) => pageErrors.push(error));
Expand Down
116 changes: 100 additions & 16 deletions packages/remix-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type * as Vite from "vite";
import { type BinaryLike, createHash } from "node:crypto";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import * as fse from "fs-extra";
import babel from "@babel/core";
import { type ServerBuild } from "@remix-run/server-runtime";
import {
Expand Down Expand Up @@ -182,8 +182,8 @@ function dedupe<T>(array: T[]): T[] {
}

const writeFileSafe = async (file: string, contents: string): Promise<void> => {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, contents);
await fse.ensureDir(path.dirname(file));
await fse.writeFile(file, contents);
};

const getRouteModuleExports = async (
Expand Down Expand Up @@ -213,7 +213,7 @@ const getRouteModuleExports = async (

let [id, code] = await Promise.all([
resolveId(),
fs.readFile(routePath, "utf-8"),
fse.readFile(routePath, "utf-8"),
// pluginContainer.transform(...) fails if we don't do this first:
moduleGraph.ensureEntryFromUrl(url, ssr),
]);
Expand Down Expand Up @@ -244,6 +244,8 @@ export type RemixVitePlugin = (
export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
let viteCommand: Vite.ResolvedConfig["command"];
let viteUserConfig: Vite.UserConfig;
let resolvedViteConfig: Vite.ResolvedConfig | undefined;

let isViteV4 = getViteMajorVersion() === 4;

let cssModulesManifest: Record<string, string> = {};
Expand Down Expand Up @@ -338,19 +340,23 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
};`;
};

let createBuildManifest = async (): Promise<Manifest> => {
let pluginConfig = await resolvePluginConfig();

let viteManifestPath = isViteV4
let loadViteManifest = async (directory: string) => {
let manifestPath = isViteV4
? "manifest.json"
: path.join(".vite", "manifest.json");
let manifestContents = await fse.readFile(
path.resolve(directory, manifestPath),
"utf-8"
);
return JSON.parse(manifestContents) as Vite.Manifest;
};

let createBuildManifest = async (): Promise<Manifest> => {
let pluginConfig = await resolvePluginConfig();

let viteManifest = JSON.parse(
await fs.readFile(
path.resolve(pluginConfig.assetsBuildDirectory, viteManifestPath),
"utf-8"
)
) as Vite.Manifest;
let viteManifest = await loadViteManifest(
pluginConfig.assetsBuildDirectory
);

let entry: Manifest["entry"] = resolveBuildAssetPaths(
pluginConfig,
Expand Down Expand Up @@ -529,6 +535,8 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
},
}
: {
ssrEmitAssets: true, // We move SSR-only assets to client assets and clean the rest
manifest: true, // We need the manifest to detect SSR-only assets
outDir: path.dirname(pluginConfig.serverBuildPath),
rollupOptions: {
...viteUserConfig.build?.rollupOptions,
Expand All @@ -549,6 +557,8 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
async configResolved(viteConfig) {
await initEsModuleLexer;

resolvedViteConfig = viteConfig;

ssrBuildContext =
viteConfig.build.ssr && viteCommand === "build"
? { isSsrBuild: true, getManifest: createBuildManifest }
Expand Down Expand Up @@ -737,6 +747,80 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
}
};
},
writeBundle: {
// After the SSR build is finished, we inspect the Vite manifest for
// the SSR build and move all server assets to client assets directory
async handler() {
if (!ssrBuildContext.isSsrBuild) {
return;
}

invariant(
cachedPluginConfig,
"Expected plugin config to be cached when writeBundle hook is called"
);

invariant(
resolvedViteConfig,
"Expected resolvedViteConfig to exist when writeBundle hook is called"
);

let { assetsBuildDirectory, serverBuildPath, rootDirectory } =
cachedPluginConfig;
let serverBuildDir = path.dirname(serverBuildPath);

let ssrViteManifest = await loadViteManifest(serverBuildDir);
let clientViteManifest = await loadViteManifest(assetsBuildDirectory);

let clientAssetPaths = new Set(
Object.values(clientViteManifest).flatMap(
(chunk) => chunk.assets ?? []
)
);

let ssrOnlyAssetPaths = new Set(
Object.values(ssrViteManifest)
.flatMap((chunk) => chunk.assets ?? [])
// Only move assets that aren't in the client build
.filter((asset) => !clientAssetPaths.has(asset))
);

let movedAssetPaths = await Promise.all(
Array.from(ssrOnlyAssetPaths).map(async (ssrAssetPath) => {
let src = path.join(serverBuildDir, ssrAssetPath);
let dest = path.join(assetsBuildDirectory, ssrAssetPath);
await fse.move(src, dest);
return dest;
})
);

let logger = resolvedViteConfig.logger;

if (movedAssetPaths.length) {
logger.info(
[
"",
`${colors.green("✓")} ${movedAssetPaths.length} asset${
movedAssetPaths.length > 1 ? "s" : ""
} moved from Remix server build to client assets.`,
...movedAssetPaths.map((movedAssetPath) =>
colors.dim(path.relative(rootDirectory, movedAssetPath))
),
"",
].join("\n")
);
}

let ssrAssetsDir = path.join(
resolvedViteConfig.build.outDir,
resolvedViteConfig.build.assetsDir
);

if (fse.existsSync(ssrAssetsDir)) {
await fse.remove(ssrAssetsDir);
}
},
},
async buildEnd() {
await viteChildCompiler?.close();
},
Expand Down Expand Up @@ -897,8 +981,8 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {

return [
"const exports = {}",
await fs.readFile(reactRefreshRuntimePath, "utf8"),
await fs.readFile(
await fse.readFile(reactRefreshRuntimePath, "utf8"),
await fse.readFile(
require.resolve("./static/refresh-utils.cjs"),
"utf8"
),
Expand Down
Loading