Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use __DEV__ internally instead of process.env.NODE_ENV. #8347

Merged
merged 4 commits into from Jun 15, 2021

Conversation

benjamn
Copy link
Member

@benjamn benjamn commented Jun 3, 2021

As explained in #8266, our use of process.env.NODE_ENV requires those expressions to be either replaced with a string literal by a minifier (common convention in the React ecosystem) or polyfilled globally.

We stopped polyfilling process.env.NODE_ENV globally in the ts-invariant package in apollographql/invariant-packages#94, but @apollo/client is still relying on process.env internally, as is the graphql package. If we want to rid ourselves fully of the drawbacks of process.env.NODE_ENV, we probably ought to stop using it ourselves.

Though most developers in the React ecosystem will be using a bundler or minifier that automatically replaces process.env.NODE_ENV at build time, you may not have the luxury of custom minification when loading @apollo/client from a CDN, which leaves only the option of a global process.env polyfill, which is problematic because that's how some applications detect if the current code is running Node.js (more details/links in #8266).

Instead, I believe we can (and should) stop using process.env.NODE_ENV, and one of many better alternatives is the boolean __DEV__ variable used by React Native and internally by React itself, which is much easier to polyfill, since it's a single variable rather than a nested object.

Switching to __DEV__ will initially cause a large bundle size regression (+3.5Kb after minification and gzip), but that's not technically a breaking change (hence acceptable for AC3.4), and it should be straightforward to reconfigure minifiers to replace __DEV__ with true or false (or even just undefined), with sufficient guidance from the release notes.

Although we could do some more work to reduce the bundle size impact of blindly switching to __DEV__, I believe this PR already solves the last remaining hurdles documented in #8266, allowing @apollo/client/core@beta to be loaded from an ESM-aware CDN like https://esm.run or https://jspm.io.

The default __DEV__ value will be true in unminified, buildless browser environments, but it can be initialized differently by a script/module that runs earlier in the HTML of the page. If process.env.NODE_ENV is available, it will determine the default value of __DEV__, namely __DEV__ === (process.env.NODE_ENV !== "production"), unless __DEV__ is already defined globally or replaced by a minifier, in which case we use it without modification.

That still leaves the process.env.NODE_ENV check in graphql/jsutils/instanceOf.mjs. Discussion in graphql/graphql-js#2894 suggests the plan is to stop using NODE_ENV altogether, which would be ideal, but that won't happen until graphql@16 at the earliest.

To work around the problem in the meantime, this PR adopts a strategy of polyfilling process.env.NODE_ENV only briefly, while importing code that depends on graphql/jsutils/instanceOf.mjs, and then synchronously removes the global polyfill, so it does not permanently pollute the global namespace, and is never visible to code outside the graphql package.

This strategy assumes @apollo/client is the first to import the graphql package. If you have other code that imports graphql/jsutils/instanceOf.mjs earlier, and you don't have a process.env.NODE_ENV strategy already, it's (still) your responsibility to make that import work, however you see fit. Apollo Client is only responsible for making sure its own imports of the graphql package have a chance of succeeding, a responsibility I believe this PR fulfills cleverly/technically, if not exactly elegantly. Of course, this charade does not (need to) happen if process.env.NODE_ENV is already defined or has been minified away, but only in cases when accessing process.env.NODE_ENV would throw a ReferenceError, since that's the specific failure we're trying to prevent within graphql.

'@process.env.NODE_ENV': JSON.stringify('production'),
'@__DEV__': 'false',
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our Filesize CI check is only passing because I went ahead and reconfigured the minifier to replace __DEV__ with false, simulating a production environment.

Comment on lines -100 to -120
// In Node.js, where these CommonJS bundles are most commonly used,
// the expression process.env.NODE_ENV can be very expensive to
// evaluate, because process.env is a wrapper for the actual OS
// environment, and lookups are not cached. We need to preserve the
// syntax of process.env.NODE_ENV expressions for dead code
// elimination to work properly, but we can apply our own caching by
// shadowing the global process variable with a stripped-down object
// that saves a snapshot of process.env.NODE_ENV when the bundle is
// first evaluated. If we ever need other process properties, we can
// add more stubs here.
intro: '!(function (process) {',
outro: [
'}).call(this, {',
' env: {',
' NODE_ENV: typeof process === "object"',
' && process.env',
' && process.env.NODE_ENV',
' || "development"',
' }',
'});',
].join('\n'),
Copy link
Member Author

@benjamn benjamn Jun 3, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes PR #7627 obsolete, because we're no longer using process.env.NODE_ENV internally, even in Node.js (where process is defined globally, but process.env lookups are surprisingly expensive).

Comment on lines +3 to +5
declare global {
const __DEV__: boolean | undefined;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: are people (especially folks already using TypeScript with React Native) going to be OK with this global declaration? Is there any way to make it less obtrusive/noticeable?

src/utilities/fixes/index.ts Outdated Show resolved Hide resolved
benjamn added a commit to apollographql/react-apollo-error-template that referenced this pull request Jun 4, 2021
@benjamn
Copy link
Member Author

benjamn commented Jun 4, 2021

Here's a recommended way to configure a Create React App application to minify __DEV__ properly, without ejecting: apollographql/react-apollo-error-template#51

benjamn added a commit that referenced this pull request Jun 4, 2021
@benjamn benjamn marked this pull request as ready for review June 4, 2021 17:51
@benjamn benjamn requested review from hwillson and brainkim June 4, 2021 17:51
@benjamn benjamn changed the title Experiment: use __DEV__ instead of process.env.NODE_ENV. Use __DEV__ internally instead of process.env.NODE_ENV. Jun 4, 2021
@benjamn benjamn changed the title Use __DEV__ internally instead of process.env.NODE_ENV. Use __DEV__ internally instead of process.env.NODE_ENV. Jun 4, 2021
@benjamn benjamn force-pushed the try-__DEV__-instead-of-process.env.NODE_ENV branch from f844243 to 023a824 Compare June 4, 2021 21:27
@hwillson
Copy link
Member

hwillson commented Jun 5, 2021

Thanks for working on this @benjamn! There is a risk that people are going to update to 3.4 and not notice the impact to their bundle size until after they’ve deployed to production. To help with this we’ll definitely call things out in the CHANGELOG and docs, but should we also consider a one time dev invariant.log message (with a link to the docs outlining how to work with __DEV__), to call this out more?

benjamn added a commit that referenced this pull request Jun 7, 2021
@benjamn
Copy link
Member Author

benjamn commented Jun 16, 2021

@abdonrd I think I can fix that too, but maybe the tool shouldn't be blindly finding/replacing __DEV__ in string literals like "./__DEV__.js"? Do you have other options for doing the replacement, ideally using a parsed AST to replace only __DEV__ identifiers that are global variable references?

Edit: If the replace plugin is similar to some other minifiers (like terser), you might be able to put an @ before __DEV__ to force the minifier to parse __DEV__ as an expression, like we do here:

replace({
  preventAssignment: true,
  values: {
    "@__DEV__": JSON.stringify(false),
  },
}),

@hwillson @brainkim We should probably collect examples of recommended configurations for different bundlers/minifiers.

@abdonrd
Copy link
Contributor

abdonrd commented Jun 17, 2021

@benjamn I'm using the official @rollup/plugin-replace.

I just tried the @, but it doesn't work.

@benjamn
Copy link
Member Author

benjamn commented Jun 17, 2021

@abdonrd Seems like a known problem: rollup/plugins#803 (closed but not really solved by rollup/plugins#798).

Following the recommendation from @jespertheend, I would suggest switching to rollup-plugin-define, which uses a parsed AST rather than string matching.

@abdonrd
Copy link
Contributor

abdonrd commented Jun 17, 2021

@benjamn I'm not sure if it's a good solution change from to this package...

Because looking at the statistics, I imagine that many more people will have this problem.

rollup-plugin-define @rollup/plugin-replace
Screenshot 2021-06-17 at 16 48 21 Screenshot 2021-06-17 at 16 48 42

@benjamn
Copy link
Member Author

benjamn commented Jun 17, 2021

I agree we should do what we can to accommodate imperfect but popular tools!

However, I would also point out that having 2MM downloads/week can put you in a position of not wanting to change anything, for fear of (temporarily) breaking all those applications, even once you've become aware of a fatal flaw in your approach. I don't know if the maintainers think of this flaw as "fatal" like I do, but they must be aware that string manipulation without actual parsing is always going to produce false positives.

We'll get this fixed, but I stand behind my person-to-person advice about switching tools! 🚀

@abdonrd
Copy link
Contributor

abdonrd commented Jun 17, 2021

You are right, thanks @benjamn !

@sebastienbarre
Copy link

sebastienbarre commented Jul 5, 2021

This is a bit of a shot in the dark, but I'm not sure where to look. Updating my project from 3.3 to 3.4.0-rc.15 breaks my app in production. It works in dev mode, but once built, I'm getting these which essentially kills my app at startup:

image

I wonder if this might be related to this change? Apologies if I'm far off.
Thank you.
I'm using snowpack/esbuild to build but I'm getting the same thing with webpack.

@benjamn
Copy link
Member Author

benjamn commented Jul 7, 2021

@sebastienbarre I believe I already answered a similar question from you here: #7399 (comment). Would you mind opening a new issue?

@sebastienbarre
Copy link

@benjamn thanks. Here you go, #8467

benjamn added a commit that referenced this pull request Jul 23, 2021
Removed the item about PR #7627 because it has been superseded by #8347.
benjamn added a commit that referenced this pull request Jul 23, 2021
Removed the item about PR #7627 because it has been superseded by #8347.
@hwillson hwillson removed this from the MM-2021-06 milestone Jul 29, 2021
benjamn added a commit to apollographql/react-apollo-error-template that referenced this pull request Jul 29, 2021
benjamn added a commit that referenced this pull request Oct 4, 2021
To import `graphql` (and its internal `instanceOf` function) for the
first time with `process.env.NODE_ENV` safely polyfilled, PR #8347
selected a somewhat arbitrary export of the `graphql` package that uses
`instanceOf`, specifically the `isType` function exported by
`graphql/type/definition`.

As revealed by issue #8705, importing `isType` was a bad choice, since
it depends on a bunch of other code within the `graphql` package,
unnecessarily increasing minified+gzip bundle size by 3.4kB.

A better choice is the `Source` constructor, which is already imported
thanks to other necessary imports, and also imports/uses `instanceOf`.
benjamn added a commit to apollographql/graphql-js that referenced this pull request Dec 13, 2021
Currently, because of the naked reference to process.env.NODE_ENV in
src/jsutils/instanceOf.ts, the `graphql` package must either be loaded
in an environment that supports/polyfills process.env, or minified in a
way that replaces process.env.NODE_ENV expressions with string literals
at build time.

As a concrete example of how this problem can manifest, consider running
the following command in your browser console:

  await import("https://cdn.jsdelivr.net/npm/graphql@16.1.0/jsutils/instanceOf.mjs")

Even though instanceOf.mjs is otherwise a valid ECMAScript module,
importing it in a browser throws the following exception:

  instanceOf.mjs:13 Uncaught ReferenceError: process is not defined
    at instanceOf.mjs:12:3

To make matters worse, polyfilling globalThis.process is not a safe
default option in general, because many programs use the presence of the
globalThis.process object to determine if they're running in Node.js.

In order to allow the `@apollo/client` package to run natively (without
a build step or globalThis.process polyfill) in browsers, we had to come
up with an extensive workaround that temporarily polyfills
globalThis.process.env while importing the `graphql` package, and then
immediately removes the polyfill (if it was previously undefined):
apollographql/apollo-client#8347

This PR is my attempt to make those hacks no longer necessary, by making
the `graphql` package more defensive in the way it detects
process.env.NODE_ENV, but also without interrupting various existing
ways in which process.env.NODE_ENV is exposed/handled/transformed in a
typical web application. In short, I use maybe(() =>
process.env.NODE_ENV) in place of just process.env.NODE_ENV, and
maybe(...) makes things safe.

The maybe(() => ...) utility is a convenient way to evaluate an
arbitrary expression like process.env.NODE_ENV safely in any
environment, even if not all parts of the expression are defined:

1. If process.env.NODE_ENV is defined, the maybe(() =>
   process.env.NODE_ENV) expression will correctly infer its type
   (string | undefined) and return a value of that type when called.

2. If evaluating process.env.NODE_ENV throws an exception (because
   either process or process.env are not defined), the maybe(...)
   expression will evaluate safely to undefined.

3. If a build tool replaces process.env.NODE_ENV expressions with string
   literals, the resulting expressions maybe(() => "production") or
   maybe(() => "development") will work as expected.

Other solutions like `typeof process === "object"` and
process?.env?.NODE_ENV chaining fail to satisfy one or more of these
requirements (especially the last one, given the variety of different
build tools used), and tend to require more code.
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 1, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
No open projects
Development

Successfully merging this pull request may close these issues.

Fix remaining barriers to loading @apollo/client/core as native ES modules from a CDN
4 participants