Skip to content

Commit 8cd31d6

Browse files
hi-ogawamarkdalgleishpcattori
committed
fix(remix-dev/vite): use ssrEmitAssets to support assets referenced by server-only code (#7892)
Co-authored-by: Mark Dalgleish <[email protected]> Co-authored-by: Pedro Cattori <[email protected]>
1 parent a82c05e commit 8cd31d6

File tree

3 files changed

+147
-16
lines changed

3 files changed

+147
-16
lines changed

.changeset/tricky-frogs-film.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/dev": patch
3+
---
4+
5+
Emit assets that were only referenced in the server build into the client assets directory in Vite build

integration/vite-build-test.ts

+42
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ test.describe("Vite build", () => {
3232
import mdx from "@mdx-js/rollup";
3333
3434
export default defineConfig({
35+
build: {
36+
// force emitting asset files instead of inlined as data-url
37+
assetsInlineLimit: 0,
38+
},
3539
plugins: [
3640
remix(),
3741
mdx(),
@@ -183,6 +187,29 @@ test.describe("Vite build", () => {
183187
return <div data-dotenv-route-loader-content>{loaderContent}</div>;
184188
}
185189
`,
190+
191+
"app/routes/ssr-assets.tsx": js`
192+
import url1 from "../assets/test1.txt?url";
193+
import url2 from "../assets/test2.txt?url";
194+
import { useLoaderData } from "@remix-run/react"
195+
196+
export const loader: LoaderFunction = () => {
197+
return { url2 };
198+
};
199+
200+
export default function SsrAssetRoute() {
201+
const loaderData = useLoaderData();
202+
return (
203+
<div>
204+
<a href={url1}>url1</a>
205+
<a href={loaderData.url2}>url2</a>
206+
</div>
207+
);
208+
}
209+
`,
210+
211+
"app/assets/test1.txt": "test1",
212+
"app/assets/test2.txt": "test2",
186213
},
187214
});
188215

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

282+
test("emits SSR assets to the client assets directory", async ({ page }) => {
283+
let app = new PlaywrightFixture(appFixture, page);
284+
await app.goto("/ssr-assets");
285+
286+
// verify asset files are emitted and served correctly
287+
await page.getByRole("link", { name: "url1" }).click();
288+
await page.waitForURL("**/build/assets/test1-*.txt");
289+
await page.getByText("test1").click();
290+
await page.goBack();
291+
292+
await page.getByRole("link", { name: "url2" }).click();
293+
await page.waitForURL("**/build/assets/test2-*.txt");
294+
await page.getByText("test2").click();
295+
});
296+
255297
test("supports code-split css", async ({ page }) => {
256298
let pageErrors: unknown[] = [];
257299
page.on("pageerror", (error) => pageErrors.push(error));

packages/remix-dev/vite/plugin.ts

+100-16
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type * as Vite from "vite";
44
import { type BinaryLike, createHash } from "node:crypto";
55
import * as path from "node:path";
6-
import * as fs from "node:fs/promises";
6+
import * as fse from "fs-extra";
77
import babel from "@babel/core";
88
import { type ServerBuild } from "@remix-run/server-runtime";
99
import {
@@ -182,8 +182,8 @@ function dedupe<T>(array: T[]): T[] {
182182
}
183183

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

189189
const getRouteModuleExports = async (
@@ -213,7 +213,7 @@ const getRouteModuleExports = async (
213213

214214
let [id, code] = await Promise.all([
215215
resolveId(),
216-
fs.readFile(routePath, "utf-8"),
216+
fse.readFile(routePath, "utf-8"),
217217
// pluginContainer.transform(...) fails if we don't do this first:
218218
moduleGraph.ensureEntryFromUrl(url, ssr),
219219
]);
@@ -244,6 +244,8 @@ export type RemixVitePlugin = (
244244
export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
245245
let viteCommand: Vite.ResolvedConfig["command"];
246246
let viteUserConfig: Vite.UserConfig;
247+
let resolvedViteConfig: Vite.ResolvedConfig | undefined;
248+
247249
let isViteV4 = getViteMajorVersion() === 4;
248250

249251
let cssModulesManifest: Record<string, string> = {};
@@ -338,19 +340,23 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
338340
};`;
339341
};
340342

341-
let createBuildManifest = async (): Promise<Manifest> => {
342-
let pluginConfig = await resolvePluginConfig();
343-
344-
let viteManifestPath = isViteV4
343+
let loadViteManifest = async (directory: string) => {
344+
let manifestPath = isViteV4
345345
? "manifest.json"
346346
: path.join(".vite", "manifest.json");
347+
let manifestContents = await fse.readFile(
348+
path.resolve(directory, manifestPath),
349+
"utf-8"
350+
);
351+
return JSON.parse(manifestContents) as Vite.Manifest;
352+
};
353+
354+
let createBuildManifest = async (): Promise<Manifest> => {
355+
let pluginConfig = await resolvePluginConfig();
347356

348-
let viteManifest = JSON.parse(
349-
await fs.readFile(
350-
path.resolve(pluginConfig.assetsBuildDirectory, viteManifestPath),
351-
"utf-8"
352-
)
353-
) as Vite.Manifest;
357+
let viteManifest = await loadViteManifest(
358+
pluginConfig.assetsBuildDirectory
359+
);
354360

355361
let entry: Manifest["entry"] = resolveBuildAssetPaths(
356362
pluginConfig,
@@ -529,6 +535,8 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
529535
},
530536
}
531537
: {
538+
ssrEmitAssets: true, // We move SSR-only assets to client assets and clean the rest
539+
manifest: true, // We need the manifest to detect SSR-only assets
532540
outDir: path.dirname(pluginConfig.serverBuildPath),
533541
rollupOptions: {
534542
...viteUserConfig.build?.rollupOptions,
@@ -549,6 +557,8 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
549557
async configResolved(viteConfig) {
550558
await initEsModuleLexer;
551559

560+
resolvedViteConfig = viteConfig;
561+
552562
ssrBuildContext =
553563
viteConfig.build.ssr && viteCommand === "build"
554564
? { isSsrBuild: true, getManifest: createBuildManifest }
@@ -737,6 +747,80 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
737747
}
738748
};
739749
},
750+
writeBundle: {
751+
// After the SSR build is finished, we inspect the Vite manifest for
752+
// the SSR build and move all server assets to client assets directory
753+
async handler() {
754+
if (!ssrBuildContext.isSsrBuild) {
755+
return;
756+
}
757+
758+
invariant(
759+
cachedPluginConfig,
760+
"Expected plugin config to be cached when writeBundle hook is called"
761+
);
762+
763+
invariant(
764+
resolvedViteConfig,
765+
"Expected resolvedViteConfig to exist when writeBundle hook is called"
766+
);
767+
768+
let { assetsBuildDirectory, serverBuildPath, rootDirectory } =
769+
cachedPluginConfig;
770+
let serverBuildDir = path.dirname(serverBuildPath);
771+
772+
let ssrViteManifest = await loadViteManifest(serverBuildDir);
773+
let clientViteManifest = await loadViteManifest(assetsBuildDirectory);
774+
775+
let clientAssetPaths = new Set(
776+
Object.values(clientViteManifest).flatMap(
777+
(chunk) => chunk.assets ?? []
778+
)
779+
);
780+
781+
let ssrOnlyAssetPaths = new Set(
782+
Object.values(ssrViteManifest)
783+
.flatMap((chunk) => chunk.assets ?? [])
784+
// Only move assets that aren't in the client build
785+
.filter((asset) => !clientAssetPaths.has(asset))
786+
);
787+
788+
let movedAssetPaths = await Promise.all(
789+
Array.from(ssrOnlyAssetPaths).map(async (ssrAssetPath) => {
790+
let src = path.join(serverBuildDir, ssrAssetPath);
791+
let dest = path.join(assetsBuildDirectory, ssrAssetPath);
792+
await fse.move(src, dest);
793+
return dest;
794+
})
795+
);
796+
797+
let logger = resolvedViteConfig.logger;
798+
799+
if (movedAssetPaths.length) {
800+
logger.info(
801+
[
802+
"",
803+
`${colors.green("✓")} ${movedAssetPaths.length} asset${
804+
movedAssetPaths.length > 1 ? "s" : ""
805+
} moved from Remix server build to client assets.`,
806+
...movedAssetPaths.map((movedAssetPath) =>
807+
colors.dim(path.relative(rootDirectory, movedAssetPath))
808+
),
809+
"",
810+
].join("\n")
811+
);
812+
}
813+
814+
let ssrAssetsDir = path.join(
815+
resolvedViteConfig.build.outDir,
816+
resolvedViteConfig.build.assetsDir
817+
);
818+
819+
if (fse.existsSync(ssrAssetsDir)) {
820+
await fse.remove(ssrAssetsDir);
821+
}
822+
},
823+
},
740824
async buildEnd() {
741825
await viteChildCompiler?.close();
742826
},
@@ -897,8 +981,8 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
897981

898982
return [
899983
"const exports = {}",
900-
await fs.readFile(reactRefreshRuntimePath, "utf8"),
901-
await fs.readFile(
984+
await fse.readFile(reactRefreshRuntimePath, "utf8"),
985+
await fse.readFile(
902986
require.resolve("./static/refresh-utils.cjs"),
903987
"utf8"
904988
),

0 commit comments

Comments
 (0)