Skip to content

fix: declare the published packages side-effect free so unused components can be tree-shaken - #2610

Open
martinfrancois wants to merge 1 commit into
visgl:masterfrom
martinfrancois:fix/declare-side-effects-free
Open

fix: declare the published packages side-effect free so unused components can be tree-shaken#2610
martinfrancois wants to merge 1 commit into
visgl:masterfrom
martinfrancois:fix/declare-side-effects-free

Conversation

@martinfrancois

Copy link
Copy Markdown

Fixes #2609.

The problem

An app that imports six things from react-map-gl/maplibre:

import {AttributionControl, Layer, Map, NavigationControl, Popup, Source} from 'react-map-gl/maplibre'

still ships ScaleControl, GeolocateControl, TerrainControl, FullscreenControl, GlobeControl, LogoControl and Marker.

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, no import './styles.css'. The sideEffects field is how a package says that, and none of the three published packages sets it, so bundlers keep everything.

The change

"sideEffects": false in modules/main, modules/react-mapbox and modules/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, GeolocateControl and friends, so those names appear in the output whatever react-map-gl does.

before after
react-map-gl modules bundled 26 18
unused control wrappers present 7 of 7 0 of 7
used components present 6 of 6 6 of 6
client chunks, raw 2,774,929 B 2,771,505 B
client chunks, gzipped 815,085 B 814,253 B

Dropped: scale-control, geolocate-control, terrain-control, fullscreen-control, globe-control, logo-control, marker, and the barrel index. 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: false is 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):

  • No CSS imports.
  • No module-scope statements that do work. Every top-level line is an import, an export, or a declaration.
  • No writes to window, globalThis, document or self at module scope.
  • The only top-level calls are memo(...) and forwardRef(...), which wrap a component and return it.

There is one prototype assignment in the tree, and it does not run on import:

// modules/react-maplibre/src/maplibre/maplibre.ts, inside _initialize()
if (props.gl) {
    const getContext = HTMLCanvasElement.prototype.getContext
    HTMLCanvasElement.prototype.getContext = () => {
        HTMLCanvasElement.prototype.getContext = getContext  // restores itself
        return props.gl
    }
}

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 the memo(...) and forwardRef(...) 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 once sideEffects was 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, Source and Layer, then assert the output does not reference scale-control. It fails today and passes after this change.

Testing

  • yarn lint → clean, 285 files checked
  • yarn test-node → 16 test files, 24 tests, all passing

The 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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Missing sideEffects declaration means consumers ship components they never import

1 participant