From deac0780307e18ee74c7e4e2311915aef881204c Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Tue, 20 Feb 2024 16:10:11 -0800 Subject: [PATCH] feat: support additional Vite `build.rollupOptions.input` --- integration/vite-build-test.ts | 777 ++++++++++++++++-------------- packages/remix-dev/vite/plugin.ts | 37 +- 2 files changed, 458 insertions(+), 356 deletions(-) diff --git a/integration/vite-build-test.ts b/integration/vite-build-test.ts index 3fec1a9b55b..f4c8a75291f 100644 --- a/integration/vite-build-test.ts +++ b/integration/vite-build-test.ts @@ -11,392 +11,461 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture, selectHtml } from "./helpers/playwright-fixture.js"; -test.describe("Vite build", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture({ - compiler: "vite", - files: { - "remix.config.js": js` - throw new Error("Remix should not access remix.config.js when using Vite"); - export default {}; - `, - ".env": ` - ENV_VAR_FROM_DOTENV_FILE=true - `, - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { vitePlugin as remix } from "@remix-run/dev"; - import mdx from "@mdx-js/rollup"; - - export default defineConfig({ - build: { - // force emitting asset files instead of inlined as data-url - assetsInlineLimit: 0, - }, - plugins: [ - mdx(), - remix(), - ], - }); - `, - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; - - export default function Root() { - return ( - - - - - - -
-

Root

- -
- - - - ); - } - `, - "app/routes/_index.tsx": js` - import { useState, useEffect } from "react"; - import { json } from "@remix-run/node"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return json({ serverOnly1 }) - } - - export const action = () => { - console.log(serverOnly2) - return null - } - - export default function() { - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - return ( - <> -

Index

- {!mounted ?

Loading...

:

Mounted

} - - ); - } - `, - "app/utils.server.ts": js` - export const serverOnly1 = "SERVER_ONLY_1" - export const serverOnly2 = "SERVER_ONLY_2" - `, - "app/routes/resource.ts": js` - import { json } from "@remix-run/node"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return json({ serverOnly1 }) - } - - export const action = () => { - console.log(serverOnly2) - return null - } - `, - "app/routes/mdx.mdx": js` - import { useEffect, useState } from "react"; - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return json({ - serverOnly1, - content: "MDX route content from loader", - }) - } - - export const action = () => { - console.log(serverOnly2) - return null - } - - export function MdxComponent() { - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - const { content } = useLoaderData(); - const text = content + (mounted - ? ": mounted" - : ": not mounted"); - return
{text}
- } - - ## MDX Route - - - `, - "app/routes/code-split1.tsx": js` - import { CodeSplitComponent } from "../code-split-component"; - - export default function CodeSplit1Route() { - return
; - } - `, - "app/routes/code-split2.tsx": js` - import { CodeSplitComponent } from "../code-split-component"; - - export default function CodeSplit2Route() { - return
; - } - `, - "app/code-split-component.tsx": js` - import classes from "./code-split.module.css"; - - export function CodeSplitComponent() { - return ok - } - `, - "app/code-split.module.css": js` - .test { - background-color: rgb(255, 170, 0); - } - `, - "app/routes/dotenv.tsx": js` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export const loader = () => { - return json({ - loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE ?? '.env file was NOT loaded, which is a good thing', - }) - } - - export default function DotenvRoute() { - const { loaderContent } = useLoaderData(); - - return
{loaderContent}
; - } - `, - - "app/assets/test.txt": "test", - "app/routes/ssr-only-assets.tsx": js` - import txtUrl from "../assets/test.txt?url"; - import { useLoaderData } from "@remix-run/react" - - export const loader: LoaderFunction = () => { - return { txtUrl }; - }; - - export default function SsrOnlyAssetsRoute() { - const loaderData = useLoaderData(); - return ( -
- txtUrl -
- ); - } - `, - - "app/assets/test.css": ".test{color:red}", - "app/routes/ssr-only-css-url-files.tsx": js` - import cssUrl from "../assets/test.css?url"; - import { useLoaderData } from "@remix-run/react" - - export const loader: LoaderFunction = () => { - return { cssUrl }; - }; - - export default function SsrOnlyCssUrlFilesRoute() { - const loaderData = useLoaderData(); - return ( -
- cssUrl -
- ); - } - `, - - "app/routes/ssr-code-split.tsx": js` - import { useLoaderData } from "@remix-run/react" - - export const loader: LoaderFunction = async () => { - const lib = await import("../ssr-code-split-lib"); - return lib.ssrCodeSplitTest(); - }; - - export default function SsrCodeSplitRoute() { - const loaderData = useLoaderData(); - return ( -
{loaderData}
- ); - } - `, - - "app/ssr-code-split-lib.ts": js` - export function ssrCodeSplitTest() { - return "ssrCodeSplitTest"; - } - `, +const baseFiles = { + "remix.config.js": js` + throw new Error("Remix should not access remix.config.js when using Vite"); + export default {}; + `, + ".env": ` + ENV_VAR_FROM_DOTENV_FILE=true + `, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + import mdx from "@mdx-js/rollup"; + + export default defineConfig({ + build: { + // force emitting asset files instead of inlined as data-url + assetsInlineLimit: 0, }, + plugins: [ + mdx(), + remix(), + ], }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+

Root

+ +
+ + + + ); + } + `, + "app/routes/_index.tsx": js` + import { useState, useEffect } from "react"; + import { json } from "@remix-run/node"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return json({ serverOnly1 }) + } + + export const action = () => { + console.log(serverOnly2) + return null + } + + export default function() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( + <> +

Index

+ {!mounted ?

Loading...

:

Mounted

} + + ); + } + `, + "app/utils.server.ts": js` + export const serverOnly1 = "SERVER_ONLY_1" + export const serverOnly2 = "SERVER_ONLY_2" + `, + "app/routes/resource.ts": js` + import { json } from "@remix-run/node"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return json({ serverOnly1 }) + } + + export const action = () => { + console.log(serverOnly2) + return null + } + `, + "app/routes/mdx.mdx": js` + import { useEffect, useState } from "react"; + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return json({ + serverOnly1, + content: "MDX route content from loader", + }) + } + + export const action = () => { + console.log(serverOnly2) + return null + } + + export function MdxComponent() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + const { content } = useLoaderData(); + const text = content + (mounted + ? ": mounted" + : ": not mounted"); + return
{text}
+ } + + ## MDX Route + + + `, + "app/routes/code-split1.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; + + export default function CodeSplit1Route() { + return
; + } + `, + "app/routes/code-split2.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; + + export default function CodeSplit2Route() { + return
; + } + `, + "app/code-split-component.tsx": js` + import classes from "./code-split.module.css"; + + export function CodeSplitComponent() { + return ok + } + `, + "app/code-split.module.css": js` + .test { + background-color: rgb(255, 170, 0); + } + `, + "app/routes/dotenv.tsx": js` + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => { + return json({ + loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE ?? '.env file was NOT loaded, which is a good thing', + }) + } + + export default function DotenvRoute() { + const { loaderContent } = useLoaderData(); + + return
{loaderContent}
; + } + `, + + "app/assets/test.txt": "test", + "app/routes/ssr-only-assets.tsx": js` + import txtUrl from "../assets/test.txt?url"; + import { useLoaderData } from "@remix-run/react" + + export const loader: LoaderFunction = () => { + return { txtUrl }; + }; + + export default function SsrOnlyAssetsRoute() { + const loaderData = useLoaderData(); + return ( +
+ txtUrl +
+ ); + } + `, + + "app/assets/test.css": ".test{color:red}", + "app/routes/ssr-only-css-url-files.tsx": js` + import cssUrl from "../assets/test.css?url"; + import { useLoaderData } from "@remix-run/react" + + export const loader: LoaderFunction = () => { + return { cssUrl }; + }; + + export default function SsrOnlyCssUrlFilesRoute() { + const loaderData = useLoaderData(); + return ( +
+ cssUrl +
+ ); + } + `, + + "app/routes/ssr-code-split.tsx": js` + import { useLoaderData } from "@remix-run/react" + + export const loader: LoaderFunction = async () => { + const lib = await import("../ssr-code-split-lib"); + return lib.ssrCodeSplitTest(); + }; + + export default function SsrCodeSplitRoute() { + const loaderData = useLoaderData(); + return ( +
{loaderData}
+ ); + } + `, + + "app/ssr-code-split-lib.ts": js` + export function ssrCodeSplitTest() { + return "ssrCodeSplitTest"; + } + `, +}; - appFixture = await createAppFixture(fixture); - }); +test.describe("Vite build", () => { + test.describe("happy path", () => { + let fixture: Fixture; + let appFixture: AppFixture; + test.beforeAll(async () => { + fixture = await createFixture({ + compiler: "vite", + files: { + ...baseFiles, + }, + }); + + appFixture = await createAppFixture(fixture); + }); - test.afterAll(() => { - appFixture.close(); - }); + test.afterAll(() => { + appFixture.close(); + }); - test("server code is removed from client build", async () => { - let clientBuildDir = path.join(fixture.projectDir, "build/client"); + test("server code is removed from client build", async () => { + let clientBuildDir = path.join(fixture.projectDir, "build/client"); - // detect client asset files - let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", { - cwd: clientBuildDir, - absolute: true, - }); + // detect client asset files + let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", { + cwd: clientBuildDir, + absolute: true, + }); - // grep for server-only values in client assets - let result = shell - .grep("-l", /SERVER_ONLY_1|SERVER_ONLY_2/, assetFiles) - .stdout.trim() - .split("\n") - .filter((line) => line.length > 0); + // grep for server-only values in client assets + let result = shell + .grep("-l", /SERVER_ONLY_1|SERVER_ONLY_2/, assetFiles) + .stdout.trim() + .split("\n") + .filter((line) => line.length > 0); - expect(result).toHaveLength(0); - }); + expect(result).toHaveLength(0); + }); - test("server renders matching routes", async () => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(selectHtml(await res.text(), "#content")).toBe(`
+ test("server renders matching routes", async () => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(selectHtml(await res.text(), "#content")).toBe(`

Root

Index

Loading...

`); - }); + }); - test("hydrates", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#content h2").textContent()).toBe("Index"); - expect(await page.locator("#content h3[data-mounted]").textContent()).toBe( - "Mounted" - ); - }); + test("hydrates", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#content h2").textContent()).toBe("Index"); + expect( + await page.locator("#content h3[data-mounted]").textContent() + ).toBe("Mounted"); + }); - test("server renders matching MDX routes", async ({ page }) => { - let res = await fixture.requestDocument("/mdx"); - expect(res.status).toBe(200); - expect(selectHtml(await res.text(), "[data-mdx-route]")).toBe( - `
MDX route content from loader: not mounted
` - ); - }); + test("server renders matching MDX routes", async ({ page }) => { + let res = await fixture.requestDocument("/mdx"); + expect(res.status).toBe(200); + expect(selectHtml(await res.text(), "[data-mdx-route]")).toBe( + `
MDX route content from loader: not mounted
` + ); + }); - test("hydrates matching MDX routes", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + test("hydrates matching MDX routes", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/mdx"); - await expect(page.locator("[data-mdx-route]")).toContainText( - "MDX route content from loader: mounted" - ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/mdx"); + await expect(page.locator("[data-mdx-route]")).toContainText( + "MDX route content from loader: mounted" + ); - expect(pageErrors).toEqual([]); - }); + expect(pageErrors).toEqual([]); + }); - test("emits SSR-only assets to the client assets directory", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/ssr-only-assets"); + test("emits SSR-only assets to the client assets directory", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/ssr-only-assets"); - await page.getByRole("link", { name: "txtUrl" }).click(); - await page.waitForURL("**/assets/test-*.txt"); - await expect(page.getByText("test")).toBeVisible(); - }); + await page.getByRole("link", { name: "txtUrl" }).click(); + await page.waitForURL("**/assets/test-*.txt"); + await expect(page.getByText("test")).toBeVisible(); + }); - test("emits SSR-only .css?url files to the client assets directory", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/ssr-only-css-url-files"); + test("emits SSR-only .css?url files to the client assets directory", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/ssr-only-css-url-files"); - await page.getByRole("link", { name: "cssUrl" }).click(); - await page.waitForURL("**/assets/test-*.css"); - await expect(page.getByText(".test{")).toBeVisible(); - }); + await page.getByRole("link", { name: "cssUrl" }).click(); + await page.waitForURL("**/assets/test-*.css"); + await expect(page.getByText(".test{")).toBeVisible(); + }); - test("supports code-split JS from SSR build", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + test("supports code-split JS from SSR build", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/ssr-code-split`); - expect(pageErrors).toEqual([]); + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/ssr-code-split`); + expect(pageErrors).toEqual([]); - await expect(page.locator("[data-ssr-code-split]")).toHaveText( - "ssrCodeSplitTest" - ); + await expect(page.locator("[data-ssr-code-split]")).toHaveText( + "ssrCodeSplitTest" + ); - expect(pageErrors).toEqual([]); - }); + expect(pageErrors).toEqual([]); + }); - test("removes assets (other than code-split JS) and CSS files from SSR build", async () => { - let assetFiles = glob.sync("*", { - cwd: path.join(fixture.projectDir, "build/server/assets"), + test("removes assets (other than code-split JS) and CSS files from SSR build", async () => { + let assetFiles = glob.sync("*", { + cwd: path.join(fixture.projectDir, "build/server/assets"), + }); + let [asset, ...rest] = assetFiles; + expect(rest).toEqual([]); // Provide more useful test output if this fails + expect(asset).toMatch(/ssr-code-split-lib-.*\.js/); }); - let [asset, ...rest] = assetFiles; - expect(rest).toEqual([]); // Provide more useful test output if this fails - expect(asset).toMatch(/ssr-code-split-lib-.*\.js/); - }); - test("supports code-split css", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/code-split1"); - expect( - await page - .locator("#code-split1 span") - .evaluate((e) => window.getComputedStyle(e).backgroundColor) - ).toBe("rgb(255, 170, 0)"); - - await app.goto("/code-split2"); - expect( - await page - .locator("#code-split2 span") - .evaluate((e) => window.getComputedStyle(e).backgroundColor) - ).toBe("rgb(255, 170, 0)"); - - expect(pageErrors).toEqual([]); - }); + test("supports code-split css", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/code-split1"); + expect( + await page + .locator("#code-split1 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); + + await app.goto("/code-split2"); + expect( + await page + .locator("#code-split2 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); + + expect(pageErrors).toEqual([]); + }); - test("doesn't load .env file", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + test("doesn't load .env file", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/dotenv`); - expect(pageErrors).toEqual([]); + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/dotenv`); + expect(pageErrors).toEqual([]); - let loaderContent = page.locator("[data-dotenv-route-loader-content]"); - await expect(loaderContent).toHaveText( - ".env file was NOT loaded, which is a good thing" - ); + let loaderContent = page.locator("[data-dotenv-route-loader-content]"); + await expect(loaderContent).toHaveText( + ".env file was NOT loaded, which is a good thing" + ); - expect(pageErrors).toEqual([]); + expect(pageErrors).toEqual([]); + }); + }); + + test.describe("supports additional build.rollupOptions.input", () => { + let fixture: Fixture; + let appFixture: AppFixture; + test.beforeAll(async () => { + fixture = await createFixture({ + compiler: "vite", + files: { + ...baseFiles, + "app/additional-input.ts": js` + export const additionalInput = "additionalInput"; + `, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + import mdx from "@mdx-js/rollup"; + + export default defineConfig(({ isSsrBuild }) => ({ + build: { + // force emitting asset files instead of inlined as data-url + assetsInlineLimit: 0, + rollupOptions: { + input: isSsrBuild ? { + additional: "./app/additional-input.ts", + } : ["./app/additional-input.ts"] + }, + }, + plugins: [ + mdx(), + remix(), + ], + })); + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should emit additional entry for server build", async () => { + let clientBuildDir = path.join(fixture.projectDir, "build/client/assets"); + let serverBuildDir = path.join(fixture.projectDir, "build/server"); + + let serverEntries = glob + .sync("*.@(js)", { + cwd: serverBuildDir, + }) + .sort(); + expect(serverEntries).toEqual(["additional.js", "index.js"]); + + let clientEntries = glob + .sync("*.@(js)", { + cwd: clientBuildDir, + }) + .sort(); + expect(clientEntries.some((v) => v.startsWith("additional-input-"))).toBe( + true + ); + }); }); }); diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 0c174f22f35..1e340e03296 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -576,7 +576,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { basename: "/", buildDirectory: "build", manifest: false, - serverBuildFile: "index.js", + serverBuildFile: "[name].js", ssr: true, } as const satisfies Partial; @@ -906,6 +906,35 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { ) ); + let userConfigSSRRollupOptionsInput: + | undefined + | Record = undefined; + let userConfigClientRollupOptionsInput: string[] = []; + let userRollupInput = viteUserConfig.build?.rollupOptions?.input; + if (userRollupInput) { + if (viteConfigEnv.isSsrBuild) { + // We only allow objects because we want to enforce the remix build output is named "index" + if ( + typeof userRollupInput !== "object" || + Array.isArray(userRollupInput) + ) { + throw new Error( + "The `build.rollupOptions.input` option in `vite.config` must be an object for SSR builds" + ); + } + userConfigSSRRollupOptionsInput = userRollupInput; + } else { + // We only allow arrays because entries are all hashed anyways + if (!Array.isArray(userRollupInput)) { + throw new Error( + "The `build.rollupOptions.input` option in `vite.config` must be an array for client builds" + ); + } + userConfigClientRollupOptionsInput = userRollupInput || []; + console.log({ userConfigClientRollupOptionsInput }); + } + } + return { __remixPluginContext: ctx, appType: @@ -975,6 +1004,7 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { route.file )}${CLIENT_ROUTE_QUERY_STRING}` ), + ...userConfigClientRollupOptionsInput, ], }, } @@ -990,7 +1020,10 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { outDir: getServerBuildDirectory(ctx), rollupOptions: { preserveEntrySignatures: "exports-only", - input: serverBuildId, + input: { + ...userConfigSSRRollupOptionsInput, + index: serverBuildId, + }, output: { entryFileNames: ctx.remixConfig.serverBuildFile, format: ctx.remixConfig.serverModuleFormat,