diff --git a/.changeset/browser-envname-fallback.md b/.changeset/browser-envname-fallback.md new file mode 100644 index 0000000..b9a5049 --- /dev/null +++ b/.changeset/browser-envname-fallback.md @@ -0,0 +1,7 @@ +--- +"@vlandoss/env": patch +--- + +Clarify that the `envConfig()` Vite plugin is required for **any** non-development browser build, not just custom modes. + +In the browser, `envName()`'s `readEnv()` only reads `window.__env` — never `process.env` — so a pure SPA can't see `NODE_ENV` / `VITE_ENV`. Without the plugin's `__ENV_NAME__` inject, `envName()` falls back to `"development"` (silently shipping the dev config to every environment), regardless of the `--mode` or `VITE_ENV` used at build time. The docs and the `__ENV_NAME__` JSDoc previously stated the browser would fall back to `"production"`, which is only true on the server. diff --git a/docsite/content/docs/concepts/env-name.mdx b/docsite/content/docs/concepts/env-name.mdx index 16515bf..775d428 100644 --- a/docsite/content/docs/concepts/env-name.mdx +++ b/docsite/content/docs/concepts/env-name.mdx @@ -11,7 +11,7 @@ icon: Hash First defined wins: 1. **`env.ENV`** — explicit runtime override (always wins). Use this in CI, tests, or scripts. -2. **`__ENV_NAME__`** — build-time literal injected by the `envConfig()` Vite plugin (the env it built for: `VITE_ENV` if set, otherwise Vite's `mode`). Beats `NODE_ENV` because Vite forces `NODE_ENV="production"` regardless of `--mode`, so this is the only way `envName()` can return custom envs like `staging` / `qa` in the browser. +2. **`__ENV_NAME__`** — build-time literal injected by the `envConfig()` Vite plugin (the env it built for: `VITE_ENV` if set, otherwise Vite's `mode`). In a browser build this is the **only** source that carries the built env name into your code — `readEnv()` reads `window.__env`, never `process.env`, so without it `envName()` can't see `NODE_ENV` / `VITE_ENV` at all (see [the Vite gotcha](#the-vite-gotcha)). Placed above `NODE_ENV` so a build-time env name wins even over the `NODE_ENV="production"` that Vite forces regardless of `--mode`. 3. **`env.NODE_ENV`** 4. **`env.VITE_ENV`** 5. **`"development"`** — fallback. @@ -28,10 +28,10 @@ envName({ NODE_ENV: "test", ENV: "qa" }); // -> "qa" ## The Vite gotcha -If you run `vite build --mode staging`, Vite **still sets `NODE_ENV="production"`** at build time. Without the `envConfig()` plugin, `envName()` in browser code would return `"production"` — not `"staging"`. +In the browser, `envName()`'s `readEnv()` only sees `window.__env` (or the `` tag) — **never `process.env` or `import.meta.env`**. So in a pure SPA, none of `NODE_ENV` / `VITE_ENV` reaches `envName()`: with no plugin and no `__ENV_NAME__`, every entry in the chain is empty and `envName()` falls through to its `"development"` fallback — *regardless* of the `--mode` or `VITE_ENV` you built with. A `vite build --mode production` that worked locally will quietly ship the **development** config. -The plugin solves this by injecting `__ENV_NAME__ = "staging"` as a build-time constant (from `--mode staging` or `VITE_ENV=staging`). That constant wins over `NODE_ENV` in the precedence chain, so the browser sees the env you actually built. +The plugin solves this by injecting `__ENV_NAME__` as a build-time constant (from `VITE_ENV`, else Vite's `mode`). That constant is the only thing `envName()` can read in the browser, so it sees the env you actually built. -This is why **the plugin is required for custom Vite modes**, even if you load config via dynamic import (Pattern 1) and don't actually use the `#config` alias. +This is why **the plugin is required for any non-development browser build** — even if you load config via dynamic import (Pattern 1) and don't actually use the `#config` alias. See [Guides → SPA dynamic import](/docs/guides/spa-dynamic-import) and [Guides → Custom modes](/docs/guides/custom-modes) for the wiring. diff --git a/docsite/content/docs/guides/custom-modes.mdx b/docsite/content/docs/guides/custom-modes.mdx index b8c1c4d..3da13a7 100644 --- a/docsite/content/docs/guides/custom-modes.mdx +++ b/docsite/content/docs/guides/custom-modes.mdx @@ -6,7 +6,7 @@ icon: GitBranch ## The problem -You run `vite build --mode staging`. Vite **forces `NODE_ENV="production"`** at build time, regardless of `--mode`. So in browser code, the default precedence of `envName()` returns `"production"` — not `"staging"`. +You run `vite build --mode staging` and expect `envName()` to say `"staging"` in the browser. It won't — and the reason is subtle. In the browser, `envName()`'s `readEnv()` only reads `window.__env` (or the `` tag), **never `process.env`**. A pure SPA has neither, so `NODE_ENV` and `VITE_ENV` are invisible and `envName()` falls through to its `"development"` fallback — no matter what `--mode` you built with. (Vite even forces `NODE_ENV="production"` at build time, but that only ever reaches `envName()` on the _server_, not in browser code.) This is independent of how you load config: it bites both [Pattern 1 (dynamic import)](/docs/guides/spa-dynamic-import) and any browser code that calls `envName()` directly. diff --git a/docsite/content/docs/guides/spa-dynamic-import.mdx b/docsite/content/docs/guides/spa-dynamic-import.mdx index 9a2d95d..c389bef 100644 --- a/docsite/content/docs/guides/spa-dynamic-import.mdx +++ b/docsite/content/docs/guides/spa-dynamic-import.mdx @@ -69,7 +69,7 @@ export default defineConfig({ - **Dynamic `import()` in `defineEnv`**: Vite sees the template literal and emits one chunk per matching file under `../config/`. `defineEnv` auto-unwraps the ESM module namespace (no manual `.default`). - **`await defineEnv(...)`**: when `config` is a Promise, `defineEnv` returns `Promise>`. You can also skip the `await` and let consumers handle the Promise. -- **`envConfig()` plugin**: in this pattern you don't import `#config`, so why bring the plugin? Because it also injects `__ENV_NAME__` at build time, which is **the only way `envName()` can return non-`production` values in the browser** — Vite forces `NODE_ENV="production"` regardless of `--mode`. Without the plugin, `envName()` in browser code would always say `"production"` after a build. See [`envName()`](/docs/concepts/env-name) and [Custom modes](/docs/guides/custom-modes). +- **`envConfig()` plugin**: in this pattern you don't import `#config`, so why bring the plugin? Because it also injects `__ENV_NAME__` at build time, and that constant is **the only way the built env name reaches `envName()` in the browser** — `readEnv()` reads `window.__env`, never `process.env`, so a pure SPA can't see `NODE_ENV` / `VITE_ENV`. Without the plugin, `envName()` falls back to `"development"` after a build — silently shipping your dev config to every environment — no matter what `--mode` or `VITE_ENV` you built with. See [`envName()`](/docs/concepts/env-name) and [Custom modes](/docs/guides/custom-modes). ## Hardening chunk filenames diff --git a/package/src/lib/const.ts b/package/src/lib/const.ts index 7528b17..679a98a 100644 --- a/package/src/lib/const.ts +++ b/package/src/lib/const.ts @@ -30,7 +30,11 @@ export const ENV_GLOBAL_ID = "__env"; * return the right env name in the browser without manual `runtimeEnv` plumbing. * * Sits **between** `env.ENV` (explicit runtime override) and `env.NODE_ENV` in - * `envName()`'s precedence chain — required for non-default modes (`staging`, - * `qa`, …) because Vite forces `NODE_ENV="production"` regardless of `--mode`. + * `envName()`'s precedence chain. In the browser it's the only source `envName()` + * has: `readEnv()` reads `window.__env`, never `process.env`, so a pure SPA sees + * no `NODE_ENV` / `VITE_ENV` and falls back to `"development"` without this inject. + * That's why the plugin is required for any non-development browser build — not + * just custom modes like `staging` / `qa` (which Vite would otherwise flatten by + * forcing `NODE_ENV="production"` regardless of `--mode`). */ export const BUILD_TIME_ENV_NAME_ID = "__ENV_NAME__";