Skip to content

Commit

Permalink
fix(remix-dev/vite): use ssrEmitAssets to support assets referenced…
Browse files Browse the repository at this point in the history
… by server-only code (#7892)

Co-authored-by: Mark Dalgleish <[email protected]>
Co-authored-by: Pedro Cattori <[email protected]>
  • Loading branch information
3 people authored Nov 16, 2023
1 parent bfa2bbc commit bdc6004
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 16 deletions.
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

0 comments on commit bdc6004

Please sign in to comment.