The Shoelace Side Effect Scavenger Hunt

Alright, strap in, we’re going to be talking Treeshaking and Side Effects and the problems of tracking them down in Shoelace .

It had been reported that Shoelace bundles for React were much bigger than expected due to not being treeshaken (eliminated unused code).

Initially, I attempted to solve this with the sideEffects key in package.json, turns out, this creates a lot of problems because it requires maintaining an up-to-date list of files with side-effects, and it also bypasses a lot of bundler’s ability to optimize and detect side effects and bundlers simply take these files at face-value and don’t traverse the file.

Here’s some extra reading.

Alright, so now that we determined using sideEffects is an easy hack, albeit difficult to maintain and deoptimizes a lot of bundlers, and creates lots of problems, let’s look at how we can track down side effects in Shoelace’s code base.

The first problem is that we transpile from typescript -> javascript using ESBuild .

So, the first step was to create a “test” component with as little as possible and then pipe it through a CLI created by Rich Harris called Agadoo and detect side effects.

On compiling a new component 3 things stood out to me:

Let’s walk through the problems for each of these.


This one I expected. It has a side-effectful import. I can fix this. This is easy.


This one was a little surprising, but it makes sense.

export class MyClass {
  @property() attr

Gets compiled to this:

var MyClass = class {}

], MyClass.prototype, "attr", 2);

export { MyClass }

That __decorateClass call means that because it’s modifying the class prototype, the file is no longer tree-shakeable due to the “side effect”

Okay, its possible to work around this. I detail it here:

The TLDR is we’d need an build.onEnd plugin to iterate over all the built files and check for decorators and wrap them in an IIFE and mark them as pure like this package for Vite:

And of course the related issue on ESBuild marked as “closed” and essentially “wontfix”

So, we can just steal the plugin, its a lot, but not too bad.

Static initialization blocks

Alright, this was super tough to track down. I was using multiple ESBuild playgrounds and couldn’t figure out why it was compiling as expected there, but not in ESBuild.

So, here’s the gist of what I had + what I wanted:

class Thing {
    static thing = "thing"

I expected this to compile exactly as is. But in Shoelace, it was setting the property directly on the class.

// Expected
class Thing {
    static { this.thing = "thing" }

// Actual
class Thing {}
Thing.thing = "thing"

Which then is considered “not tree-shakeable” by Agadoo / Rollup. So what was causing this funkiness?

Well, the answer lays in tsconfig.json

  "compilerOptions": {
    "useDefineForClassFields": false

We have this set to false for a reason, and that’s to play nicely with Lit’s Reactive Property binding.

And here’s the Twitter thread of me trying trying to figure out WTF is going on.

I don’t know the path forward here, but this was me documenting my fun time with side effects and tree shaking. Hope you enjoyed my misery :)