diff --git a/.changeset/rude-keys-heal.md b/.changeset/rude-keys-heal.md new file mode 100644 index 00000000000..27dc9968f33 --- /dev/null +++ b/.changeset/rude-keys-heal.md @@ -0,0 +1,15 @@ +--- +"@remix-run/dev": patch +--- + +Vite: Preserve names for exports from .client imports + +Unlike `.server` modules, the main idea is not to prevent code from leaking into the server build +since the client build is already public. Rather, the goal is to isolate the SSR render from client-only code. +Routes need to import code from `.client` modules without compilation failing and then rely on runtime checks +to determine if the code is running on the server or client. + +Replacing `.client` modules with empty modules would cause the build to fail as ESM named imports are statically analyzed. +So instead, we preserve the named export but replace each exported value with an empty object. +That way, the import is valid at build time and the standard runtime checks can be used to determine if then +code is running on the server or client. diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 8ad0fc6beea..5c4faa4ea11 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -9,6 +9,8 @@ import resolveBin from "resolve-bin"; import stripIndent from "strip-indent"; import waitOn from "wait-on"; import getPort from "get-port"; +import shell from "shelljs"; +import glob from "glob"; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); @@ -249,3 +251,17 @@ export function createEditor(projectDir: string) { await fs.writeFile(filepath, transform(contents), "utf8"); }; } + +export function grep(cwd: string, pattern: RegExp): string[] { + let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", { + cwd, + absolute: true, + }); + + let lines = shell + .grep("-l", pattern, assetFiles) + .stdout.trim() + .split("\n") + .filter((line) => line.length > 0); + return lines; +} diff --git a/integration/vite-dot-client-test.ts b/integration/vite-dot-client-test.ts new file mode 100644 index 00000000000..95e055f3394 --- /dev/null +++ b/integration/vite-dot-client-test.ts @@ -0,0 +1,48 @@ +import * as path from "node:path"; +import { test, expect } from "@playwright/test"; + +import { createProject, grep, viteBuild } from "./helpers/vite.js"; + +let files = { + "app/utils.client.ts": String.raw` + export const dotClientFile = "CLIENT_ONLY_FILE"; + export default dotClientFile; + `, + "app/.client/utils.ts": String.raw` + export const dotClientDir = "CLIENT_ONLY_DIR"; + export default dotClientDir; + `, +}; + +test("Vite / client code excluded from server bundle", async () => { + let cwd = await createProject({ + ...files, + "app/routes/dot-client-imports.tsx": String.raw` + import { dotClientFile } from "../utils.client"; + import { dotClientDir } from "../.client/utils"; + + export default function() { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return ( + <> +

Index

+

{mounted ? dotClientFile + dotClientDir : ""}

+ + ); + } + `, + }); + let [client, server] = viteBuild({ cwd }); + expect(client.status).toBe(0); + expect(server.status).toBe(0); + let lines = grep( + path.join(cwd, "build/server"), + /CLIENT_ONLY_FILE|CLIENT_ONLY_DIR/ + ); + expect(lines).toHaveLength(0); +}); diff --git a/integration/vite-dot-server-test.ts b/integration/vite-dot-server-test.ts index 9cae06c9844..42721b39b53 100644 --- a/integration/vite-dot-server-test.ts +++ b/integration/vite-dot-server-test.ts @@ -1,9 +1,7 @@ import * as path from "node:path"; import { test, expect } from "@playwright/test"; -import shell from "shelljs"; -import glob from "glob"; -import { createProject, viteBuild } from "./helpers/vite.js"; +import { createProject, grep, viteBuild } from "./helpers/vite.js"; let files = { "app/utils.server.ts": String.raw` @@ -198,17 +196,3 @@ test("Vite / dead-code elimination for server exports", async () => { ); expect(lines).toHaveLength(0); }); - -function grep(cwd: string, pattern: RegExp): string[] { - let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", { - cwd, - absolute: true, - }); - - let lines = shell - .grep("-l", pattern, assetFiles) - .stdout.trim() - .split("\n") - .filter((line) => line.length > 0); - return lines; -} diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index a0a027a3f16..1f36f025166 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -938,14 +938,21 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { }, { name: "remix-empty-client-modules", - enforce: "pre", - async transform(_code, id, options) { + enforce: "post", + async transform(code, id, options) { if (!options?.ssr) return; let clientFileRE = /\.client(\.[cm]?[jt]sx?)?$/; let clientDirRE = /\/\.client\//; if (clientFileRE.test(id) || clientDirRE.test(id)) { + let exports = esModuleLexer(code)[1]; return { - code: "export {}", + code: exports + .map(({ n: name }) => + name === "default" + ? "export default {};" + : `export const ${name} = {};` + ) + .join("\n"), map: null, }; }