Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/browser-envname-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions docsite/content/docs/concepts/env-name.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 `<EnvScript />` 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.
2 changes: 1 addition & 1 deletion docsite/content/docs/guides/custom-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<EnvScript />` 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.

Expand Down
2 changes: 1 addition & 1 deletion docsite/content/docs/guides/spa-dynamic-import.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Env<S>>`. 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

Expand Down
8 changes: 6 additions & 2 deletions package/src/lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__";
Loading