feat(react): dual dist build strips dev-only effects from production#7874
Draft
mattcosta7 wants to merge 6 commits into
Draft
feat(react): dual dist build strips dev-only effects from production#7874mattcosta7 wants to merge 6 commits into
mattcosta7 wants to merge 6 commits into
Conversation
Wraps useEffect with a build-time __DEV__ check. Lets consumers replace the inline 'if (__DEV__) useEffect(...)' pattern that previously required an eslint-disable-next-line react-hooks/rules-of-hooks comment at every site — the conditional now lives inside a regular hook, satisfying the linter and the React Compiler's heuristics. The internal __DEV__ guard keeps the hook safe under any build pipeline that doesn't apply the strip plugin (storybook, unit tests, third parties); upcoming commits add a babel plugin and a dual rollup build that strip every useDevOnlyEffect call statement from the production dist artifact.
Every existing 'if (__DEV__) { useEffect(...) }' block had an
'eslint-disable-next-line react-hooks/rules-of-hooks' comment because the
linter couldn't prove __DEV__ was build-time constant. Replace those four
sites — UnderlineNav, Heading, Link, Banner — with the new
useDevOnlyEffect hook, which moves the conditional inside a regular hook
and satisfies Rules of Hooks without an eslint-disable.
Behavior is unchanged: useDevOnlyEffect internally guards its useEffect
call with the same 'if (__DEV__)' check, and __DEV__ is replaced with
'false' in production builds (the existing transform-replace-expressions
pipeline). Stripping the call site entirely from the production dist
artifact — including the effect closure and deps array — is the
follow-up work in the next commits.
Removes every useDevOnlyEffect(...) call statement whose identifier resolves to an import from a useDevOnlyEffect module. To be used only by the production rollup build — the development build keeps the calls and relies on __DEV__: true to make them run. After this plugin runs, the call statement and its arguments (effect callback closure and deps array) are entirely gone. The useDevOnlyEffect import binding may then be unused; rollup tree-shaking drops it and, in turn, drops the useDevOnlyEffect module from the bundle. Conservative matching: - Only direct identifier calls; aliased imports are ignored. - Only when the binding's import source includes 'useDevOnlyEffect'. - Only statement-position calls (hooks aren't valid expressions).
…acts
Refactor rollup.config.mjs into a makeConfig(mode) factory and export two
configurations: one for 'development' that emits to dist/development/ and
one for 'production' that emits to dist/production/.
Differences between the two:
- Both run the same base babel pipeline (react-compiler, macros,
add-react-displayname, dev-expression, styled-components, etc.).
- Production additionally runs babel-plugin-strip-dev-only-effect,
which removes every useDevOnlyEffect(...) call statement from the
output before the __DEV__ swap.
- babel-plugin-transform-replace-expressions is reordered to run BEFORE
dev-expression so that the __DEV__ identifier is replaced with the
real boolean literal ('true' in development, 'false' in production)
rather than dev-expression's runtime check on
process.env.NODE_ENV. Rollup then constant-folds the resulting
'if (true)' / 'if (false)' blocks during bundling.
Result, for a representative dev-only assertion site:
dist/development/UnderlineNav/UnderlineNav.js
useDevOnlyEffect(() => { ...assertions... })
dist/development/internal/hooks/useDevOnlyEffect.js
const useDevOnlyEffect = (effect, deps) => {
{ useEffect(effect, deps); } // 'if (__DEV__)' folded away
}
dist/production/UnderlineNav/UnderlineNav.js
(assertion site removed entirely — closure and deps array gone)
dist/production/internal/hooks/useDevOnlyEffect.js
(file does not exist — tree-shaken because no caller references it)
Next commit wires package.json's 'exports' field with development /
production import conditions so consumers' bundlers automatically pick
the right artifact.
Updates package.json's exports field to declare 'development' and
'production' import conditions for every public entry (root, experimental,
deprecated, next, test-helpers). Consumers' bundlers pick the matching
artifact based on their build mode:
- Development bundlers see ./dist/development/<entry>.js — full
useDevOnlyEffect call sites, all dev-only invariants live.
- Production bundlers see ./dist/production/<entry>.js — useDevOnlyEffect
call sites and effect closures removed by the strip plugin at our
build time, before publish.
Sets main / module to ./dist/production/index.js as the safer default for
tools that don't honor exports conditions (rare, but cheaper to ship dev
assertions hidden behind dead branches than to expose them).
typings stays at ./dist/index.d.ts — tsc still emits a single shared set
of declarations to the dist root (declarations are identical for both
artifacts, so there's no value in duplicating them).
sideEffects already uses 'dist/**/*.css' / 'dist/**/test-helpers.js' globs
that match both subdirectories — no change needed.
End-to-end verification (esbuild --minify, NODE_ENV='production'):
- dist/production/UnderlineNav/UnderlineNav.js: 0 useDevOnlyEffect refs.
- dist/production/internal/hooks/useDevOnlyEffect.js: not emitted
(rollup tree-shook it because no caller references it).
- Consumer prod bundle: 0 useDevOnlyEffect refs, 0 assertion message
text. The closure body, deps array, and call site are all gone.
🦋 Changeset detectedLatest commit: 2f8618f The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
|
13 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #
Summary
Replaces every
if (__DEV__) { useEffect(...) }block that needed aneslint-disable react-hooks/rules-of-hookscomment with a new internaluseDevOnlyEffecthook, and pairs it with a dualdist/development/+dist/production/build so the production artifact ships zero bytes forevery dev-only assertion site — no call, no effect-callback closure, no deps
array, and the hook implementation itself is tree-shaken away.
The public API is unchanged. Consumers' bundlers automatically pick the right
artifact via
package.json'sexportsdevelopment/productionimportconditions; no consumer code changes are required.
Motivation
Four components —
UnderlineNav,Heading,Link, andBanner— wereperforming dev-only validations through this pattern:
Two problems:
react-hooks/rules-of-hooksis disabled at every call site. The lintercan't prove
__DEV__is build-time constant, and the React Compiler bailson the file because of the inline disable. Every new dev-only effect would
need the same boilerplate, with the same risk of someone reading it as
"hooks may be called conditionally."
The closure body ships in
dist/. With the existing single-artifactbuild,
__DEV__is rewritten toprocess.env.NODE_ENV !== 'production',so the published bundle contains:
The consumer's bundler eventually DCEs that in production builds, but the
closure body is in the npm package on every install, and bundlers that
don't do iterative DCE leave the empty
ifblock plus the closureallocation in the output.
This PR fixes both — the eslint-disable goes away from consumer sites, and
the production artifact ships nothing.
What changed
1. New
useDevOnlyEffecthook (packages/react/src/internal/hooks/useDevOnlyEffect.ts)The one remaining
eslint-disablelives inside the hook itself, where it's adocumented invariant: the hook is always called unconditionally by consumers,
so Rules of Hooks is satisfied at runtime in any build. The internal
__DEV__guard is a defensive fallback for build pipelines (storybook, unit tests,
third-party consumers without our babel plugin) where
__DEV__istrue.2. Migrate the four call sites
console.warnif element isn'th1–h6console.errorif element isn't<a>or<button>throwif no title prop orBanner.TitlechildEvery
eslint-disable-next-line react-hooks/rules-of-hookscomment inconsumer code is gone; the linter and the React Compiler are both happy.
Behavior is identical in development.
3.
babel-plugin-strip-dev-only-effect(packages/react/script/babel-plugin-strip-dev-only-effect.cjs)Removes every
useDevOnlyEffect(...)call statement whose identifier resolvesto an import from a
useDevOnlyEffectmodule. Conservative matching:useDevOnlyEffect.After this runs, the call statement, its effect-callback closure, and its
deps array are all gone from the AST. The
useDevOnlyEffectimport bindingtypically becomes unused; rollup's tree-shaking drops it next.
4. Dual rollup build (packages/react/rollup.config.mjs)
Refactored into a
makeConfig(mode)factory exporting two configurations:__DEV__developmentdist/development/true(literal)productiondist/production/false(literal)A subtle ordering fix:
babel-plugin-transform-replace-expressionsnow runsbefore
babel-plugin-dev-expression. The latter rewrites__DEV__to aruntime
process.env.NODE_ENV !== 'production'check; running our literalswap first lets rollup constant-fold the resulting
if (true) { … }/if (false) { … }blocks during bundling.5.
package.jsonexports (packages/react/package.json)Every public entry (
.,./experimental,./deprecated,./next,./test-helpers) gainsdevelopmentandproductionconditions:main/moduledefault to the production artifact for tools that don'thonour
exportsconditions. Type declarations (.d.ts) stay at the distroot — they're identical for both artifacts, so there's no value in
duplicating them.
End-to-end verification
Real consumer build with esbuild +
--minify+NODE_ENV='production':Same setup in development mode (
NODE_ENV='development', no--minify):In our own dist:
Changelog
New
useDevOnlyEffecthook for dev-only side effects withouteslint-disable react-hooks/rules-of-hooks.babel-plugin-strip-dev-only-effectbuild plugin.dist/development/anddist/production/published artifacts.developmentandproductionimport conditions on every publicexportsentry.
Changed
UnderlineNav,Heading,Link, andBanneruseuseDevOnlyEffectinstead of an inline
if (__DEV__) { useEffect(...) }block. No behaviouralchange.
package.jsonmain/modulenow point at./dist/production/index.js(was
./dist/index.js). Consumers usingexportsconditions are unaffected.Removed
eslint-disable-next-line react-hooks/rules-of-hookscomments atconsumer sites (UnderlineNav, Heading, Link, Banner).
Rollout strategy
Public API is unchanged. Existing consumers automatically benefit from a
smaller production bundle on upgrade. The only theoretically observable
change is the dist directory layout — deep imports into
@primer/react/dist/...(which are off-spec and outside theexportscontract) will need to add
development/orproduction/to their path. Wedon't sanction such imports.
Testing & Reviewing
tsc --noEmitclean;eslint --max-warnings=0clean for the touchedfiles.
npm run buildsucceeds end-to-end (rollup → tsc).every
useDevOnlyEffectreference, closure body, deps array, and thehook's own module from the consumer's prod bundle.
To verify locally:
Reviewer focus:
useDevOnlyEffect's comment block — does the invariant chain(eslint-disable inside hook + plugin strip at publish + dev fallback)
make sense in five years?
dev-expression) — does that affect any other
dev-expression-rewrittenpatterns we care about?
invariantandwarningcalls still passthrough unchanged; the only thing that changes is
__DEV__resolution.package.jsonexports— isdefault → productionthe right fallbackfor tools that don't honour conditions?
Merge checklist