Skip to content

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

Description

@martinfrancois

Description

Bundlers cannot drop the components I do not import, so every app ships all of them.

My app imports six things:

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

I never import ScaleControl, GeolocateControl, TerrainControl or FullscreenControl. All four still end up in the production bundle. Counting occurrences in the built chunk that holds the map (970 KB, Next.js 16 with Turbopack):

ScaleControl         12 occurrences
GeolocateControl      8
TerrainControl        6
FullscreenControl     6
NavigationControl     6   <- this one I do use
AttributionControl    5   <- this one I do use

Why it happens. A bundler may only delete an unused module if it knows that importing that module does nothing on its own: no writing to window, no patching prototypes, no import "./styles.css". There are two ways a package can say so, and neither is used here.

  1. The sideEffects field in package.json, which means "you may drop any of my files that nobody imports from". Both packages leave it unset:

    react-map-gl@8.1.2            sideEffects: undefined
    @vis.gl/react-maplibre@8.1.2  sideEffects: undefined
    

    With the field missing, the bundler must assume the worst and keep everything.

  2. /*#__PURE__*/ annotations on top-level calls. Every component is exported as:

    // dist/components/scale-control.js
    export const ScaleControl = memo(_ScaleControl);

    memo(...) is a call that runs at module load. The bundler cannot know it is harmless, so it must keep it, and therefore the component. I counted the annotations in the published ESM build: zero. (There is one in the CJS build, which does not help ESM consumers.)

I checked that the package really is safe to mark, because sideEffects: false is a promise and a wrong one silently breaks consumers. I scanned all 31 ESM module files in @vis.gl/react-maplibre@8.1.2 for anything that runs at import time:

  • No CSS imports.
  • No top-level 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 package and it is fine:

// dist/maplibre/maplibre.js, inside _initialize()
if (props.gl) {
    const getContext = HTMLCanvasElement.prototype.getContext;
    HTMLCanvasElement.prototype.getContext = () => {
        HTMLCanvasElement.prototype.getContext = getContext;  // un-patches itself
        return props.gl;
    };
}

It is inside a method, behind an if, and it restores the original on the next call. It never runs on import.

Expected Behavior

An app that imports only Map, Source and Layer should not ship ScaleControl, GeolocateControl, TerrainControl or FullscreenControl.

Suggested fix, two parts, and the first alone already helps:

  1. Add "sideEffects": false to package.json in both react-map-gl and @vis.gl/react-maplibre. This lets bundlers drop whole unused module files such as dist/components/scale-control.js. If you would rather be cautious, the field also takes a list, for example "sideEffects": ["*.css"].
  2. Have the build emit /*#__PURE__*/ before the memo(...) and forwardRef(...) calls, for bundlers that work inside a file rather than dropping whole files. Rollup does this with output.annotateFunctionCalls; esbuild and SWC have equivalents.

A regression test that is cheap to automate: build a fixture that imports only Map, Source and Layer, then search the output for ScaleControl. Today it is present. After the fix it should be gone.

Steps to Reproduce

No CodeSandbox, because the behaviour is in the build output rather than at runtime, and a sandbox will not show it. It reproduces from the published package alone:

  1. Create any app that imports a strict subset from react-map-gl/maplibre, for example only Map, Source and Layer.
  2. Build for production with any tree-shaking bundler (I used Next.js 16 with Turbopack; webpack and Vite behave the same way for the same reason).
  3. Search the emitted chunks for ScaleControl. It is there.

You can also see the cause without building anything:

node -e "console.log(require('@vis.gl/react-maplibre/package.json').sideEffects)"   # undefined
grep -c "__PURE__" node_modules/@vis.gl/react-maplibre/dist/components/*.js         # 0

Environment

  • Framework version: react-map-gl@8.1.2 (and @vis.gl/react-maplibre@8.1.2)
  • Map library: maplibre-gl@6.3.0
  • Browser: Chrome 140
  • OS: Linux, Next.js 16.3.1 with Turbopack

Logs

None. This is a build-output issue, not a runtime error.

Honest note on impact

I do not want to oversell this. The four unused controls are small React components. I profiled my map page on a mobile CPU profile: MapLibre itself costs 770 ms of main-thread time, of which only 147 ms (19%) is parsing and evaluating the bundle. So removing the unused controls can only touch a slice of that 19%.

The reason to fix it is not speed on my site. It is that every consumer pays for code they never asked for, the main fix is a one-line field, and I checked that the field is safe to set.

Assumptions and preconditions

  • I inspected the published dist of 8.1.2, not the TypeScript source. If src/ has an import-time side effect that the build strips, my scan would not have seen it, so someone who knows the codebase should confirm against src/.
  • The benefit only reaches consumers who import through ESM. Anyone on the CJS build (dist/index.cjs) sees no change, since CJS cannot be tree-shaken this way.

Disclosure: I found and investigated this with the help of an AI coding assistant. I directed the work, verified the findings against the published package myself, and I own what is written above.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions