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
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

All notable changes to this project are documented in this file.

[Unreleased](https://github.com/dotenvx/dotenvx/compare/v4.0.0...main)
## [Unreleased](https://github.com/dotenvx/react-native-dotenv/compare/v4.0.0...main)

## [4.0.0](https://github.com/dotenvx/dotenvx/compare/v3.4.12...v4.0.0) (2026-07-28)
### Added

- Log `◇ injected env (N) from .env` to stderr on transform (same style as dotenv). Set `quiet: true` to suppress.

## [4.0.0](https://github.com/dotenvx/react-native-dotenv/compare/v3.4.12...v4.0.0) (2026-07-28)

### Fixed

Expand Down
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ fetch(`${API_URL}/users`)

That's it. Your environment variables from `.env` are available via `@env`.

On transform you'll see a message on stderr (same style as dotenv):

```text
◇ injected env (2) from .env
```

Set `quiet: true` in the plugin options to suppress it.

 

## Advanced
Expand Down Expand Up @@ -204,7 +212,7 @@ When set to `false`, an error will be thrown.
</details>
<details><summary><code>verbose</code> (default: <code>false</code>)</summary><br>

Print the active dotenv mode while transforming.
Also print the active dotenv mode to stderr while transforming.

```json
{
Expand All @@ -216,6 +224,21 @@ Print the active dotenv mode while transforming.
}
```

</details>
<details><summary><code>quiet</code> (default: <code>false</code>)</summary><br>

Suppress the stderr inject message (`◇ injected env (N) from .env`).

```json
{
"plugins": [
["module:react-native-dotenv", {
"quiet": true
}]
]
}
```

</details>
<details><summary>process.env</summary><br>

Expand All @@ -233,9 +256,37 @@ For host/CI-only values, use `@env` imports — or put the key in `.env` and let
</details>
<details><summary>Expo</summary><br>

Expo now has [built-in environment variable support](https://docs.expo.dev/guides/environment-variables/). Evaluate if you still need this plugin.
Expo has [built-in environment variable support](https://docs.expo.dev/guides/environment-variables/). Use this plugin when you want `@env` imports or multi-env files (e.g. `.env.staging` via `APP_ENV`).

```js
// babel.config.js
module.exports = function (api) {
api.cache(false)
return {
presets: ['babel-preset-expo'],
plugins: [
['module:react-native-dotenv']
]
}
}
```

```ini
# .env
HELLO="Universe"
```

```js
// app/index.tsx
import { HELLO } from '@env'
import { Text } from 'react-native'

export default function HomeScreen() {
return <Text>Hello {HELLO}</Text>
}
```

Preview [the expo test app](https://github.com/goatandsheep/react-native-dotenv-expo-test).
Then start with a clean Metro cache: `npx expo start --clear`.

</details>
<details><summary>Multi-env</summary><br>
Expand Down
57 changes: 42 additions & 15 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,26 @@ const path = require('path')
const dotenv = require('dotenv')

function parseDotenvFile (filepath, verbose = false) {
let content

try {
content = fs.readFileSync(filepath)
const content = fs.readFileSync(filepath)
return { parsed: dotenv.parse(content), exists: true }
} catch (error) {
// The env file does not exist.
if (verbose) {
console.error('react-native-dotenv', error)
}

return {}
return { parsed: {}, exists: false }
}
}

return dotenv.parse(content) //
function logInjectedEnv (fileEnv, loadedPaths) {
const keysCount = Object.keys(fileEnv).length
if (loadedPaths.length > 0) {
console.error(`◇ injected env (${keysCount}) from ${loadedPaths.join(', ')}`)
} else {
console.error(`◇ injected env (${keysCount})`)
}
}

function undefObjectAssign (targetObject, sourceObject) {
Expand Down Expand Up @@ -67,6 +73,7 @@ module.exports = (api, options) => {
safe: false,
allowUndefined: true,
verbose: false,
quiet: false,
...options
}
const babelMode = process.env[options.envName] || (process.env.BABEL_ENV && process.env.BABEL_ENV !== 'undefined' && process.env.BABEL_ENV !== 'development' && process.env.BABEL_ENV) || process.env.NODE_ENV || 'development'
Expand All @@ -75,7 +82,7 @@ module.exports = (api, options) => {
const modeLocalFilePath = options.path + '.' + babelMode + '.local'

if (options.verbose) {
console.log('dotenvMode', babelMode)
console.error(`◇ dotenvMode ${babelMode}`)
if (process.env[options.envName] === 'production' || process.env[options.envName] === 'development') {
console.error('APP_ENV error', 'cannot use APP_ENV=development or APP_ENV=production')
}
Expand All @@ -87,12 +94,18 @@ module.exports = (api, options) => {
api.cache.using(() => mtime(modeLocalFilePath))

const dotenvTemporary = undefObjectAssign({}, process.env)
const parsed = parseDotenvFile(options.path, options.verbose)
const localParsed = parseDotenvFile(localFilePath, options.verbose)
const modeParsed = parseDotenvFile(modeFilePath, options.verbose)
const modeLocalParsed = parseDotenvFile(modeLocalFilePath, options.verbose)
const parsedFile = parseDotenvFile(options.path, options.verbose)
const localFile = parseDotenvFile(localFilePath, options.verbose)
const modeFile = parseDotenvFile(modeFilePath, options.verbose)
const modeLocalFile = parseDotenvFile(modeLocalFilePath, options.verbose)
const modeExceptions = ['NODE_ENV', 'BABEL_ENV', options.envName]
const fileEnv = undefObjectAssign(undefObjectAssign(undefObjectAssign(parsed, modeParsed), localParsed), modeLocalParsed)
const fileEnv = undefObjectAssign(
undefObjectAssign(
undefObjectAssign(parsedFile.parsed, modeFile.parsed),
localFile.parsed
),
modeLocalFile.parsed
)

// process.env.X is only inlined for keys from .env files (+ mode exceptions).
// That stops build-tooling pollution (e.g. Metro jest-worker) without hardcoding
Expand All @@ -106,10 +119,24 @@ module.exports = (api, options) => {
? safeObjectAssign(undefObjectAssign({}, fileEnv), dotenvTemporary, modeExceptions)
: undefObjectAssign(undefObjectAssign({}, fileEnv), dotenvTemporary)

api.addExternalDependency(path.resolve(options.path))
api.addExternalDependency(path.resolve(modeFilePath))
api.addExternalDependency(path.resolve(localFilePath))
api.addExternalDependency(path.resolve(modeLocalFilePath))
if (options.verbose || !options.quiet) {
const loadedPaths = [
[options.path, parsedFile.exists],
[modeFilePath, modeFile.exists],
[localFilePath, localFile.exists],
[modeLocalFilePath, modeLocalFile.exists]
]
.filter(([, exists]) => exists)
.map(([filepath]) => filepath)
logInjectedEnv(fileEnv, loadedPaths)
}

if (typeof api.addExternalDependency === 'function') {
api.addExternalDependency(path.resolve(options.path))
api.addExternalDependency(path.resolve(modeFilePath))
api.addExternalDependency(path.resolve(localFilePath))
api.addExternalDependency(path.resolve(modeLocalFilePath))
}

return ({
name: 'dotenv-import',
Expand Down
33 changes: 27 additions & 6 deletions tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ describe('react-native-dotenv', () => {
}

const OLD_ENV = process.env
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
console.error.mockRestore()
jest.resetModules()
process.env = { ...OLD_ENV }
})
Expand All @@ -31,10 +35,29 @@ describe('react-native-dotenv', () => {
})

it('should print the environment if setting to verbose', () => {
console.log = jest.fn()
const { code } = transformFileSync(FIXTURES + 'verbose/source.js')
expect(code).toBe('console.log("abc123");\nconsole.log("username");')
expect(console.log.mock.calls[0][1]).toBe('test')
expect(console.error.mock.calls.some(call => String(call[0]).includes('dotenvMode test'))).toBe(true)
})

it('should log injected env to stderr', () => {
jest.resetModules()
transformSync('import { API_KEY } from "@env"; console.log(API_KEY)', {
configFile: false,
babelrc: false,
plugins: [[require('../index.js'), { path: FIXTURES + 'default/.env' }]]
})
expect(console.error.mock.calls.some(call => /^◇ injected env \(\d+\) from /.test(String(call[0])))).toBe(true)
})

it('should not log injected env when quiet', () => {
jest.resetModules()
transformSync('import { API_KEY } from "@env"', {
configFile: false,
babelrc: false,
plugins: [[require('../index.js'), { path: FIXTURES + 'default/.env', quiet: true }]]
})
expect(console.error.mock.calls.some(call => String(call[0]).includes('injected env'))).toBe(false)
})

it('should allow importing variables already defined in the environment', () => {
Expand Down Expand Up @@ -151,21 +174,19 @@ describe('react-native-dotenv', () => {
})

it('should fail to load APP_ENV development', () => {
console.error = jest.fn()
process.env.APP_ENV = 'development'

const { code } = transformFileSync(FIXTURES + 'app-env-development/source.js')
expect(code).toBe('console.log("never");\nconsole.log("this-should-not-appear");')
expect(console.error.mock.calls[0][0]).toBe('APP_ENV error')
expect(console.error.mock.calls.some(call => call[0] === 'APP_ENV error')).toBe(true)
})

it('should fail to load APP_ENV production', () => {
console.error = jest.fn()
process.env.APP_ENV = 'production'

const { code } = transformFileSync(FIXTURES + 'app-env-production/source.js')
expect(code).toBe('console.log("never");\nconsole.log("this-should-not-appear");')
expect(console.error.mock.calls[0][0]).toBe('APP_ENV error')
expect(console.error.mock.calls.some(call => call[0] === 'APP_ENV error')).toBe(true)
})

it('should load MY_ENV specific env file', () => {
Expand Down
Loading