fix: declare the published packages side-effect free so unused components can be tree-shaken - #2610
Open
martinfrancois wants to merge 1 commit into
Open
Conversation
Without a `sideEffects` field a bundler has to assume that importing any module
might do something on its own, so it cannot drop the components an app never
imports. An app that imports only Map, Source and Layer still ships
ScaleControl, GeolocateControl, TerrainControl, FullscreenControl, GlobeControl,
LogoControl and Marker.
Measured on a Next.js 16 app that imports AttributionControl, Layer, Map,
NavigationControl, Popup and Source from react-map-gl/maplibre, by reading the
module list out of the build's source maps:
before 26 modules bundled, all 7 unused control wrappers present
after 18 modules bundled, 0 unused control wrappers present
all 6 used components still present
client chunks 2774929 -> 2771505 bytes raw, 815085 -> 814253 gzipped
Same result with webpack and with Turbopack.
The packages are safe to mark. Across all three module trees there are no CSS
imports and no module-scope statements that execute work; every top-level line
is an import, an export or a declaration. The only top-level calls are the
memo() and forwardRef() wrappers, which return a component and do nothing else.
The one prototype assignment, the getContext hijack in maplibre.ts, sits inside
a method behind an `if (props.gl)` and restores the original on the next call,
so it never runs on import.
Also tried adding /*#__PURE__*/ to the memo() and forwardRef() calls, which is
the usual companion change. It made no further difference once the field was
set, so it is not included here.
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.
Fixes #2609.
The problem
An app that imports six things from
react-map-gl/maplibre:still ships
ScaleControl,GeolocateControl,TerrainControl,FullscreenControl,GlobeControl,LogoControlandMarker.A bundler may only delete a module that nobody imports if it knows that importing it does nothing on its own: no writing to
window, no patching prototypes, noimport './styles.css'. ThesideEffectsfield is how a package says that, and none of the three published packages sets it, so bundlers keep everything.The change
"sideEffects": falseinmodules/main,modules/react-mapboxandmodules/react-maplibre. Three lines.Measured, before and after
On a Next.js 16 app importing the six components above. I read the module list out of the build's source maps rather than grepping the bundle, because the minified output is not reliable for this: MapLibre exports its own
ScaleControl,GeolocateControland friends, so those names appear in the output whatever react-map-gl does.Dropped:
scale-control,geolocate-control,terrain-control,fullscreen-control,globe-control,logo-control,marker, and the barrelindex. Nothing the app uses was affected. Same result under both webpack and Turbopack.Please read the size honestly: this is 3.4 kB raw and 832 bytes gzipped. It is not a performance fix and I would not want it merged on that basis. The argument is that consumers should not carry code they never imported, and the fix is one field.
Why the packages are safe to mark
sideEffects: falseis a promise, and a wrong one silently deletes code from consumers, so I checked rather than assumed. Across all three module trees (modules/main,modules/react-mapbox,modules/react-maplibre):import, anexport, or a declaration.window,globalThis,documentorselfat module scope.memo(...)andforwardRef(...), which wrap a component and return it.There is one prototype assignment in the tree, and it does not run on import:
It is inside a method, behind an
if, and puts the original back on the next call.Something I tried and left out
Adding
/*#__PURE__*/before thememo(...)andforwardRef(...)calls is the usual companion change, for bundlers that work inside a file rather than dropping whole files. I patched the built output to test it and it made no further difference oncesideEffectswas set, so I have not included it. Worth knowing if it comes up in review.A regression test, if you want one
Cheap to automate: build a fixture that imports only
Map,SourceandLayer, then assert the output does not referencescale-control. It fails today and passes after this change.Testing
yarn lint→ clean, 285 files checkedyarn test-node→ 16 test files, 24 tests, all passingThe change is package metadata only, so no source behaviour changes and nothing new needed a test.
Disclosure: I found this while profiling my own site and investigated it with an AI coding assistant. I directed the work, ran and verified every measurement above myself, and I own this change.