Skip to content

Commit

Permalink
feat(babel): add process.env.EXPO_OS (#27509)
Browse files Browse the repository at this point in the history
# Why

Add support for detecting the platform without needing to import
`react-native` or `react-native-web`.

<!--
Please describe the motivation for this PR, and link to relevant GitHub
issues, forums posts, or feature requests.
-->

# How

Add a babel plugin to replace `process.env.EXPO_OS` with the current
`caller.platform` environment variable. Unlike `Platform.OS` which
performs fake minifying ahead of time, this value cannot be used to
remove unused require statements. We can fix this when we add tree
shaking in the future.

This transform can be used in any node module and is not limited to
application code, similar to NODE_ENV.

<!--
How did you build this feature or fix this bug and why?
-->

# Test Plan

- Added tests for the babel transforms and sanity tests for minification
to better understand the limitations of code removal in Metro.

---------

Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com>
  • Loading branch information
EvanBacon and expo-bot committed Mar 8, 2024
1 parent 9cb0ed2 commit 91b7ef4
Show file tree
Hide file tree
Showing 11 changed files with 238 additions and 18 deletions.
2 changes: 2 additions & 0 deletions docs/pages/guides/tree-shaking.mdx
Expand Up @@ -92,6 +92,8 @@ module.exports = function (api) {

</Collapsible>

Starting in SDK 51, `process.env.EXPO_OS` can be used to detect the platform that the JavaScript was bundled for (cannot change at runtime). This value does not support platform shaking imports due to how Metro minifies code after dependency resolution.

## Remove development-only code

In your project, there might be code designed to help with the development process. It should be excluded from the production bundle. To handle these scenarios, use the `process.env.NODE_ENV `environment variable or the non-standard `__DEV__` global boolean.
Expand Down
1 change: 1 addition & 0 deletions packages/babel-preset-expo/CHANGELOG.md
Expand Up @@ -6,6 +6,7 @@

### 🎉 New features

- Add support for using `process.env.EXPO_OS` to detect the platform without platform shaking imports. ([#27509](https://github.com/expo/expo/pull/27509) by [@EvanBacon](https://github.com/EvanBacon))
- Add basic `react-server` support. ([#27264](https://github.com/expo/expo/pull/27264) by [@EvanBacon](https://github.com/EvanBacon))

### 🐛 Bug fixes
Expand Down
15 changes: 7 additions & 8 deletions packages/babel-preset-expo/build/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

104 changes: 104 additions & 0 deletions packages/babel-preset-expo/src/__tests__/minify-sanity.test.ts
@@ -0,0 +1,104 @@
// Run a number of basic operations on the minifier to ensure it works as expected
import * as babel from '@babel/core';

import { minifyLikeMetroAsync } from './minify-util';
import preset from '..';

function getCaller(props: Record<string, string | boolean>): babel.TransformCaller {
return props as unknown as babel.TransformCaller;
}

const DEFAULT_OPTS = {
babelrc: false,
presets: [[preset]],
plugins: [
// Fold constants to emulate Metro
require('metro-transform-plugins/src/constant-folding-plugin.js'),
],
sourceMaps: true,
filename: 'unknown',
configFile: false,
compact: true,
comments: false,
retainLines: false,
};

it(`removes unused functions in development`, async () => {
const options = {
...DEFAULT_OPTS,
caller: getCaller({
name: 'metro',
engine: 'hermes',
platform: 'android',
isDev: false,
}),
};

const src = `
function foo() {}
function bar() { foo() }
`;
expect((await minifyLikeMetroAsync(babel.transform(src, options)!)).code).toBe('');
});

it(`retains exported functions`, async () => {
const options = {
...DEFAULT_OPTS,
caller: getCaller({
name: 'metro',
engine: 'hermes',
platform: 'android',
isDev: false,
// Disable CJS
supportsStaticESM: true,
}),
};

const src = `
export function foo() {}
`;
expect((await minifyLikeMetroAsync(babel.transform(src, options)!)).code).toBe(
'export function foo(){}'
);
});

it(`does not remove top level variables due to module=false in the minifier (not passed from transformer)`, async () => {
const options = {
...DEFAULT_OPTS,
caller: getCaller({
name: 'metro',
engine: 'hermes',
platform: 'android',
isDev: false,
}),
};

const src = `
const a = 0;
`;
expect((await minifyLikeMetroAsync(babel.transform(src, options)!)).code).toBe('var a=0;');
});

it(`can remove unused functions based on platform-specific checks`, async () => {
const options = {
...DEFAULT_OPTS,
caller: getCaller({
name: 'metro',
engine: 'hermes',
platform: 'android',
isDev: false,
supportsStaticESM: true,
}),
};

// noop should be removed when bundling for android
const src = `
function noop() {}
function android() {}
export const value = process.env.EXPO_OS === 'android' ? android : noop;
`;
expect((await minifyLikeMetroAsync(babel.transform(src, options)!)).code).toBe(
'function android(){}export var value=android;'
);
});
26 changes: 26 additions & 0 deletions packages/babel-preset-expo/src/__tests__/minify-util.ts
@@ -0,0 +1,26 @@
// Rough estimation of how minifying works by default in Expo / Metro.
// We'll need to update this if we ever change the default minifier.
import getDefaultConfig from 'metro-config/src/defaults';
import metroMinify from 'metro-minify-terser';

export async function minifyLikeMetroAsync({
code,
map,
}: {
code?: string | null;
map?: any;
}): Promise<{ code?: string; map?: any }> {
if (code == null) throw new Error('code is required for minifying');
// @ts-expect-error: untyped function
const terserConfig = (await getDefaultConfig('/')).transformer.minifierConfig;
return metroMinify({
code,
map,
reserved: [],
config: {
...terserConfig,
// TODO: Enable module support
// compress: { module: true },
},
});
}
82 changes: 82 additions & 0 deletions packages/babel-preset-expo/src/__tests__/platform-shaking.test.ts
@@ -1,5 +1,6 @@
import * as babel from '@babel/core';

import { minifyLikeMetroAsync } from './minify-util';
import preset from '..';

function getCaller(props: Record<string, string | boolean>): babel.TransformCaller {
Expand Down Expand Up @@ -30,6 +31,31 @@ function stripReactNativeImport(code: string) {
.replace('var _reactNative=require("react-native");', '');
}

it(`does not remove Platform module in development`, () => {
const options = {
...DEFAULT_OPTS,
caller: getCaller({ name: 'metro', engine: 'hermes', platform: 'web', isDev: true }),
};

const sourceCode = `
import { Platform } from 'react-native';
if (Platform.OS === 'ios') {
console.log('ios')
}
Platform.select({
ios: () => console.log('ios'),
web: () => console.log('web'),
android: () => console.log('android'),
})
`;

expect(stripReactNativeImport(babel.transform(sourceCode, options)!.code!)).toMatch(
/_Platform\.default\.OS===/
);
});

it(`removes Platform module without import (undefined behavior)`, () => {
const options = {
...DEFAULT_OPTS,
Expand Down Expand Up @@ -155,5 +181,61 @@ it(`removes __DEV__ usage`, () => {
}
`;

// No minfication needed here, the babel plugin does it to ensure the imports are removed before dependencies are collected.
expect(babel.transform(sourceCode, options)!.code!).toEqual(``);
});

describe('process.env.EXPO_OS', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});

[true, false].forEach((isDev) => {
['development', 'test', 'production'].forEach((env) => {
it(`inlines process.env.EXPO_OS usage in NODE_ENV=${env} when bundling for dev=${isDev}`, () => {
process.env.NODE_ENV = env;
const options = {
babelrc: false,
presets: [preset],
filename: 'unknown',
// Make the snapshot easier to read
retainLines: true,
caller: getCaller({ name: 'metro', platform: 'ios', isDev }),
};

expect(babel.transform('process.env.EXPO_OS', options)!.code).toBe('"ios";');
expect(
babel.transform('process.env.EXPO_OS', {
...options,
caller: getCaller({ name: 'metro', platform: 'web', isDev }),
})!.code
).toBe('"web";');
});
});
});

it(`can use process.env.EXPO_OS to minify`, async () => {
const options = {
babelrc: false,
presets: [preset],
filename: 'unknown',
// Make the snapshot easier to read
compact: true,
caller: getCaller({ name: 'metro', platform: 'ios', isDev: false }),
};

const src = `
if (process.env.EXPO_OS === 'ios') {
console.log('ios');
}
`;

const results = babel.transform(src, options)!;
const min = await minifyLikeMetroAsync(results);
expect(min.code).toBe("console.log('ios');");
});
});
16 changes: 8 additions & 8 deletions packages/babel-preset-expo/src/index.ts
Expand Up @@ -138,17 +138,17 @@ function babelPresetExpo(api: ConfigAPI, options: BabelPresetExpoOptions = {}):
);
}

const inlineEnv: Record<string, string> = {
EXPO_OS: platform,
};

// Allow jest tests to redefine the environment variables.
if (process.env.NODE_ENV !== 'test') {
extraPlugins.push([
expoInlineTransformEnvVars,
{
// These values should not be prefixed with `EXPO_PUBLIC_`, so we don't
// squat user-defined environment variables.
EXPO_BASE_URL: baseUrl,
},
]);
// These values should not be prefixed with `EXPO_PUBLIC_`, so we don't
// squat user-defined environment variables.
inlineEnv['EXPO_BASE_URL'] = baseUrl;
}
extraPlugins.push([expoInlineTransformEnvVars, inlineEnv]);

// Only apply in non-server, for metro-only, in production environments, when the user hasn't disabled the feature.
// Webpack uses DefinePlugin for environment variables.
Expand Down
2 changes: 2 additions & 0 deletions packages/expo/CHANGELOG.md
Expand Up @@ -6,6 +6,8 @@

### 🎉 New features

- Add global type for `process.env.EXPO_OS` value. ([#27509](https://github.com/expo/expo/pull/27509) by [@EvanBacon](https://github.com/EvanBacon))

### 🐛 Bug fixes

- Fixed breaking changes from React-Native 0.74. ([#26357](https://github.com/expo/expo/pull/26357) by [@kudo](https://github.com/kudo))
Expand Down
3 changes: 2 additions & 1 deletion packages/expo/build/environment/DevLoadingView.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/expo/build/environment/DevLoadingView.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/expo/src/ts-declarations/process.d.ts
Expand Up @@ -12,6 +12,9 @@ declare const process: {

/** Maps to the `experiments.baseUrl` property in the project Expo config. This is injected by `babel-preset-expo` and supports automatic cache invalidation. */
EXPO_BASE_URL?: string;

/** Build-time representation of the `Platform.OS` value that the current JavaScript was bundled for. Does not support platform shaking wrapped require statements. */
EXPO_OS?: string;
};
[key: string]: any;
};

0 comments on commit 91b7ef4

Please sign in to comment.