diff --git a/.changeset/tricky-news-thank.md b/.changeset/tricky-news-thank.md new file mode 100644 index 00000000000..a6d15da749f --- /dev/null +++ b/.changeset/tricky-news-thank.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Populate `process.env` from `.env` files on the server in Vite dev diff --git a/integration/vite-build-test.ts b/integration/vite-build-test.ts index df13c3d8513..83b279bb5b2 100644 --- a/integration/vite-build-test.ts +++ b/integration/vite-build-test.ts @@ -23,6 +23,9 @@ test.describe("Vite build", () => { 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 { unstable_vitePlugin as remix } from "@remix-run/dev"; @@ -164,6 +167,22 @@ test.describe("Vite build", () => { 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}
; + } + `, }, }); @@ -254,4 +273,20 @@ test.describe("Vite build", () => { expect(pageErrors).toEqual([]); }); + + 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 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([]); + }); }); diff --git a/integration/vite-dev-express-test.ts b/integration/vite-dev-express-test.ts index d4eee18151e..27ddf476d63 100644 --- a/integration/vite-dev-express-test.ts +++ b/integration/vite-dev-express-test.ts @@ -117,6 +117,38 @@ test.beforeAll(async () => { ); } `, + ".env": ` + ENV_VAR_FROM_DOTENV_FILE=Content from .env file + `, + "app/routes/dotenv.tsx": js` + import { useState, useEffect } from "react"; + 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, + }) + } + + export default function DotenvRoute() { + const { loaderContent } = useLoaderData(); + + const [clientContent, setClientContent] = useState(''); + useEffect(() => { + try { + setClientContent("process.env.ENV_VAR_FROM_DOTENV_FILE shouldn't be available on the client, found: " + process.env.ENV_VAR_FROM_DOTENV_FILE); + } catch (err) { + setClientContent("process.env.ENV_VAR_FROM_DOTENV_FILE not available on the client, which is a good thing"); + } + }, []); + + return <> +
{loaderContent}
+
{clientContent}
+ + } + `, }, }); dev = await node(projectDir, ["./server.mjs"], { port }); @@ -126,185 +158,207 @@ test.afterAll(async () => { await kill(dev.pid); }); -test("Vite custom server HMR & HDR", async ({ page }) => { - // setup: initial render - await page.goto(`http://localhost:${dev.port}/`, { - waitUntil: "networkidle", - }); - await expect(page.locator("#index [data-title]")).toHaveText("Index"); - - // setup: hydration - await expect(page.locator("#index [data-mounted]")).toHaveText( - "Mounted: yes" - ); - - // setup: browser state - let hmrStatus = page.locator("#index [data-hmr]"); - await expect(page).toHaveTitle("HMR updated title: 0"); - await expect(hmrStatus).toHaveText("HMR updated: 0"); - let input = page.locator("#index input"); - await expect(input).toBeVisible(); - await input.type("stateful"); - - // route: HMR - await edit("app/routes/_index.tsx", (contents) => - contents - .replace("HMR updated title: 0", "HMR updated title: 1") - .replace("HMR updated: 0", "HMR updated: 1") - ); - await page.waitForLoadState("networkidle"); - await expect(page).toHaveTitle("HMR updated title: 1"); - await expect(hmrStatus).toHaveText("HMR updated: 1"); - await expect(input).toHaveValue("stateful"); - - // route: add loader - await edit("app/routes/_index.tsx", (contents) => - contents - .replace( - "// imports", - `// imports\nimport { json } from "@remix-run/node";\nimport { useLoaderData } from "@remix-run/react"` - ) - .replace( - "// loader", - `// loader\nexport const loader = ({ context }) => json({ message: "HDR updated: 0", context });` - ) - .replace( - "// hooks", - "// hooks\nconst { message, context } = useLoaderData();" - ) - .replace( - "{/* elements */}", - `{/* elements */}\n

{context.value}

\n

{message}

` - ) - ); - await page.waitForLoadState("networkidle"); - await expect(page.locator("#index [data-context]")).toHaveText("context"); - let hdrStatus = page.locator("#index [data-hdr]"); - await expect(hdrStatus).toHaveText("HDR updated: 0"); - // React Fast Refresh cannot preserve state for a component when hooks are added or removed - await expect(input).toHaveValue(""); - await input.type("stateful"); - - // route: HDR - await edit("app/routes/_index.tsx", (contents) => - contents.replace("HDR updated: 0", "HDR updated: 1") - ); - await page.waitForLoadState("networkidle"); - await expect(hdrStatus).toHaveText("HDR updated: 1"); - await expect(input).toHaveValue("stateful"); - - // route: HMR + HDR - await edit("app/routes/_index.tsx", (contents) => - contents - .replace("HMR updated: 1", "HMR updated: 2") - .replace("HDR updated: 1", "HDR updated: 2") - ); - await page.waitForLoadState("networkidle"); - await expect(hmrStatus).toHaveText("HMR updated: 2"); - await expect(hdrStatus).toHaveText("HDR updated: 2"); - await expect(input).toHaveValue("stateful"); - - // create new non-route component module - await fs.writeFile( - path.join(projectDir, "app/component.tsx"), - js` +test.describe("Vite custom Express server", () => { + test("handles HMR & HDR", async ({ page }) => { + // setup: initial render + await page.goto(`http://localhost:${dev.port}/`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + + // setup: hydration + await expect(page.locator("#index [data-mounted]")).toHaveText( + "Mounted: yes" + ); + + // setup: browser state + let hmrStatus = page.locator("#index [data-hmr]"); + await expect(page).toHaveTitle("HMR updated title: 0"); + await expect(hmrStatus).toHaveText("HMR updated: 0"); + let input = page.locator("#index input"); + await expect(input).toBeVisible(); + await input.type("stateful"); + + // route: HMR + await edit("app/routes/_index.tsx", (contents) => + contents + .replace("HMR updated title: 0", "HMR updated title: 1") + .replace("HMR updated: 0", "HMR updated: 1") + ); + await page.waitForLoadState("networkidle"); + await expect(page).toHaveTitle("HMR updated title: 1"); + await expect(hmrStatus).toHaveText("HMR updated: 1"); + await expect(input).toHaveValue("stateful"); + + // route: add loader + await edit("app/routes/_index.tsx", (contents) => + contents + .replace( + "// imports", + `// imports\nimport { json } from "@remix-run/node";\nimport { useLoaderData } from "@remix-run/react"` + ) + .replace( + "// loader", + `// loader\nexport const loader = ({ context }) => json({ message: "HDR updated: 0", context });` + ) + .replace( + "// hooks", + "// hooks\nconst { message, context } = useLoaderData();" + ) + .replace( + "{/* elements */}", + `{/* elements */}\n

{context.value}

\n

{message}

` + ) + ); + await page.waitForLoadState("networkidle"); + await expect(page.locator("#index [data-context]")).toHaveText("context"); + let hdrStatus = page.locator("#index [data-hdr]"); + await expect(hdrStatus).toHaveText("HDR updated: 0"); + // React Fast Refresh cannot preserve state for a component when hooks are added or removed + await expect(input).toHaveValue(""); + await input.type("stateful"); + + // route: HDR + await edit("app/routes/_index.tsx", (contents) => + contents.replace("HDR updated: 0", "HDR updated: 1") + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: 1"); + await expect(input).toHaveValue("stateful"); + + // route: HMR + HDR + await edit("app/routes/_index.tsx", (contents) => + contents + .replace("HMR updated: 1", "HMR updated: 2") + .replace("HDR updated: 1", "HDR updated: 2") + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: 2"); + await expect(hdrStatus).toHaveText("HDR updated: 2"); + await expect(input).toHaveValue("stateful"); + + // create new non-route component module + await fs.writeFile( + path.join(projectDir, "app/component.tsx"), + js` export function MyComponent() { return

Component HMR: 0

; } `, - "utf8" - ); - await edit("app/routes/_index.tsx", (contents) => - contents - .replace( - "// imports", - `// imports\nimport { MyComponent } from "../component";` - ) - .replace("{/* elements */}", "{/* elements */}\n") - ); - await page.waitForLoadState("networkidle"); - let component = page.locator("#index [data-component]"); - await expect(component).toBeVisible(); - await expect(component).toHaveText("Component HMR: 0"); - await expect(input).toHaveValue("stateful"); - - // non-route: HMR - await edit("app/component.tsx", (contents) => - contents.replace("Component HMR: 0", "Component HMR: 1") - ); - await page.waitForLoadState("networkidle"); - await expect(component).toHaveText("Component HMR: 1"); - await expect(input).toHaveValue("stateful"); - - // create new non-route server module - await fs.writeFile( - path.join(projectDir, "app/indirect-hdr-dep.ts"), - js`export const indirect = "indirect 0"`, - "utf8" - ); - await fs.writeFile( - path.join(projectDir, "app/direct-hdr-dep.ts"), - js` + "utf8" + ); + await edit("app/routes/_index.tsx", (contents) => + contents + .replace( + "// imports", + `// imports\nimport { MyComponent } from "../component";` + ) + .replace("{/* elements */}", "{/* elements */}\n") + ); + await page.waitForLoadState("networkidle"); + let component = page.locator("#index [data-component]"); + await expect(component).toBeVisible(); + await expect(component).toHaveText("Component HMR: 0"); + await expect(input).toHaveValue("stateful"); + + // non-route: HMR + await edit("app/component.tsx", (contents) => + contents.replace("Component HMR: 0", "Component HMR: 1") + ); + await page.waitForLoadState("networkidle"); + await expect(component).toHaveText("Component HMR: 1"); + await expect(input).toHaveValue("stateful"); + + // create new non-route server module + await fs.writeFile( + path.join(projectDir, "app/indirect-hdr-dep.ts"), + js`export const indirect = "indirect 0"`, + "utf8" + ); + await fs.writeFile( + path.join(projectDir, "app/direct-hdr-dep.ts"), + js` import { indirect } from "./indirect-hdr-dep" export const direct = "direct 0 & " + indirect `, - "utf8" - ); - await edit("app/routes/_index.tsx", (contents) => - contents - .replace( - "// imports", - `// imports\nimport { direct } from "../direct-hdr-dep"` - ) - .replace( - `json({ message: "HDR updated: 2", context })`, - `json({ message: "HDR updated: " + direct, context })` - ) - ); - await page.waitForLoadState("networkidle"); - await expect(hdrStatus).toHaveText("HDR updated: direct 0 & indirect 0"); - await expect(input).toHaveValue("stateful"); - - // non-route: HDR for direct dependency - await edit("app/direct-hdr-dep.ts", (contents) => - contents.replace("direct 0 &", "direct 1 &") - ); - await page.waitForLoadState("networkidle"); - await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 0"); - await expect(input).toHaveValue("stateful"); - - // non-route: HDR for indirect dependency - await edit("app/indirect-hdr-dep.ts", (contents) => - contents.replace("indirect 0", "indirect 1") - ); - await page.waitForLoadState("networkidle"); - await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 1"); - await expect(input).toHaveValue("stateful"); - - // everything everywhere all at once - await Promise.all([ - edit("app/routes/_index.tsx", (contents) => + "utf8" + ); + await edit("app/routes/_index.tsx", (contents) => contents - .replace("HMR updated: 2", "HMR updated: 3") - .replace("HDR updated: ", "HDR updated: route & ") - ), - edit("app/component.tsx", (contents) => - contents.replace("Component HMR: 1", "Component HMR: 2") - ), - edit("app/direct-hdr-dep.ts", (contents) => - contents.replace("direct 1 &", "direct 2 &") - ), - edit("app/indirect-hdr-dep.ts", (contents) => - contents.replace("indirect 1", "indirect 2") - ), - ]); - await page.waitForLoadState("networkidle"); - await expect(hmrStatus).toHaveText("HMR updated: 3"); - await expect(component).toHaveText("Component HMR: 2"); - await expect(hdrStatus).toHaveText( - "HDR updated: route & direct 2 & indirect 2" - ); - await expect(input).toHaveValue("stateful"); + .replace( + "// imports", + `// imports\nimport { direct } from "../direct-hdr-dep"` + ) + .replace( + `json({ message: "HDR updated: 2", context })`, + `json({ message: "HDR updated: " + direct, context })` + ) + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: direct 0 & indirect 0"); + await expect(input).toHaveValue("stateful"); + + // non-route: HDR for direct dependency + await edit("app/direct-hdr-dep.ts", (contents) => + contents.replace("direct 0 &", "direct 1 &") + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 0"); + await expect(input).toHaveValue("stateful"); + + // non-route: HDR for indirect dependency + await edit("app/indirect-hdr-dep.ts", (contents) => + contents.replace("indirect 0", "indirect 1") + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 1"); + await expect(input).toHaveValue("stateful"); + + // everything everywhere all at once + await Promise.all([ + edit("app/routes/_index.tsx", (contents) => + contents + .replace("HMR updated: 2", "HMR updated: 3") + .replace("HDR updated: ", "HDR updated: route & ") + ), + edit("app/component.tsx", (contents) => + contents.replace("Component HMR: 1", "Component HMR: 2") + ), + edit("app/direct-hdr-dep.ts", (contents) => + contents.replace("direct 1 &", "direct 2 &") + ), + edit("app/indirect-hdr-dep.ts", (contents) => + contents.replace("indirect 1", "indirect 2") + ), + ]); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: 3"); + await expect(component).toHaveText("Component HMR: 2"); + await expect(hdrStatus).toHaveText( + "HDR updated: route & direct 2 & indirect 2" + ); + await expect(input).toHaveValue("stateful"); + }); + + test("loads .env file", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${dev.port}/dotenv`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let loaderContent = page.locator("[data-dotenv-route-loader-content]"); + await expect(loaderContent).toHaveText("Content from .env file"); + + let clientContent = page.locator("[data-dotenv-route-client-content]"); + await expect(clientContent).toHaveText( + "process.env.ENV_VAR_FROM_DOTENV_FILE not available on the client, which is a good thing" + ); + + expect(pageErrors).toEqual([]); + }); }); async function edit(file: string, transform: (contents: string) => string) { diff --git a/integration/vite-dev-test.ts b/integration/vite-dev-test.ts index da775d3321d..ebed5d87669 100644 --- a/integration/vite-dev-test.ts +++ b/integration/vite-dev-test.ts @@ -165,6 +165,38 @@ test.describe("Vite dev", () => { `, + ".env": ` + ENV_VAR_FROM_DOTENV_FILE=Content from .env file + `, + "app/routes/dotenv.tsx": js` + import { useState, useEffect } from "react"; + 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, + }) + } + + export default function DotenvRoute() { + const { loaderContent } = useLoaderData(); + + const [clientContent, setClientContent] = useState(''); + useEffect(() => { + try { + setClientContent("process.env.ENV_VAR_FROM_DOTENV_FILE shouldn't be available on the client, found: " + process.env.ENV_VAR_FROM_DOTENV_FILE); + } catch (err) { + setClientContent("process.env.ENV_VAR_FROM_DOTENV_FILE not available on the client, which is a good thing"); + } + }, []); + + return <> +
{loaderContent}
+
{clientContent}
+ + } + `, }, }); @@ -299,6 +331,26 @@ test.describe("Vite dev", () => { expect(pageErrors).toEqual([]); }); + + test("loads .env file", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${devPort}/dotenv`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let loaderContent = page.locator("[data-dotenv-route-loader-content]"); + await expect(loaderContent).toHaveText("Content from .env file"); + + let clientContent = page.locator("[data-dotenv-route-client-content]"); + await expect(clientContent).toHaveText( + "process.env.ENV_VAR_FROM_DOTENV_FILE not available on the client, which is a good thing" + ); + + expect(pageErrors).toEqual([]); + }); }); let bufferize = (stream: Readable): (() => string) => { diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 9f401b7b01c..a45fa1cf3cd 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -11,6 +11,7 @@ import { type ResolvedConfig as ResolvedViteConfig, type ViteDevServer, type UserConfig as ViteUserConfig, + loadEnv as viteLoadEnv, normalizePath as viteNormalizePath, createServer as createViteDevServer, } from "vite"; @@ -458,6 +459,18 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { let pluginConfig = await resolvePluginConfig(); cachedPluginConfig = pluginConfig; + Object.assign( + process.env, + viteLoadEnv( + viteConfigEnv.mode, + pluginConfig.rootDirectory, + // We override default prefix of "VITE_" with a blank string since + // we're targeting the server, so we want to load all environment + // variables, not just those explicitly marked for the client + "" + ) + ); + return { appType: "custom", experimental: { hmrPartialAccept: true },