esbuild icon indicating copy to clipboard operation
esbuild copied to clipboard

Confused by __PURE__ behavior

Open KTibow opened this issue 9 months ago • 2 comments

I'm using @__PURE__ comments with and I'm getting some weird behavior, almost like ESBuild only applies one pass of tree shaking when I use --minify --tree-shaking.

If I have this code:

import { Server } from "./server/index.js";
import { manifest } from "./server/manifest.js";
const server = /* @__PURE__ */ new Server(manifest);
/* @__PURE__ */ server.init({
  env: process.env
});

Even though the equivalent of server.init isn't emitted, server is, in the form of const n=new e(r);.

I know that ESBuild understands that server is pure because if I remove the server.init call all traces of server are gone... wait, except for its imports?! A very similar thing happens again, where even though the equivalent of server has vanished from the bundle, you have to fully remove server from the input to turn import{manifest as o}from"./server/manifest.js"; into import"./server/manifest.js"; in the output.

Apologies if this is a duplicate issue, but I couldn't find anything similar with my keyword searching.

Workaround: you can use minify-syntax instead of minify and then run it as many times as needed.

KTibow avatar May 24 '25 17:05 KTibow

Tree shaking in esbuild doesn't iterate until a fixed-point has been reached like some other minifiers. Instead there is a series of reductions that happen in the parser, some removal of code with top-level statement granularity in the linker, and then some further reductions in the printer. The removal of server.init here likely happens late in the printer because the result is unused, but that's after linking so that doesn't remove const server.

The code you provided is strange. The __PURE__ annotation isn't intended to be used on top-level function calls. It's means "remove this if the result is unused" but the result is always unused for top-level function calls, so in that case why is it there in the first place? The "correct" thing for esbuild to do given these annotations is to always delete the call to server.init even if server is used, since that's what the annotations you provided mean. So your input code essentially means this to an optimizer:

import { Server } from "./server/index.js";
import { manifest } from "./server/manifest.js";
const server = /* @__PURE__ */ new Server(manifest);
process.env;

The only reason why server.init is still there is because the top-level statement containing it also contains process.env which is a property access, and property accesses could have arbitrary side effects, and esbuild's linker does tree-shaking at a top-level statement granularity. This is an esbuild limitation (which is ok correctness-wise as removing dead code is best-effort, and __PURE__ isn't meant to be used like this).

The underlying problem is probably that the __PURE__ annotation is being used incorrectly here. The general solution to marking arbitrary code as removable if it's unused is to put it all in an immediately-invoked function expression. So do something like this instead:

import { Server } from "./server/index.js";
import { manifest } from "./server/manifest.js";
const server = /* @__PURE__ */ (() => {
  const server = new Server(manifest);
  server.init({
    env: process.env
  });
  return server;
})();

evanw avatar May 24 '25 18:05 evanw

I see. Let me explain myself.

I was modifying somebody else's bundled server setup to remove some functionality that wasn't being used at runtime. Since I didn't want to manually go through the hundreds of lines that could be removed now that it was unused in the code, I thought it would be cleaner to add some __PURE__ annotations and let esbuild do the rest.

Looking back, I probably shouldn't have used __PURE__ for this. I was trying to express "declaring a server has no side effect" (which is true), but that didn't finish the job since server was still used. I also had to say "server.init has no side effect", which is untrue! As you point out, it makes sense for it to be at the top level and not return anything because it does have side effects. It only affects server, but it is a side effect.

I suppose I should've just removed all code for initializing server instead of trying to apply __PURE__ to each reference. Since the functions that reference server would also be removed by esbuild because I removed where they were called, this wouldn't be problematic. I could've also made a PR like "move server initialization to a pure IIFE" to the project that bundled the setup, although it would probably be rejected because I would be the only person to benefit from it.

If the issue is my confusion, it's solved. I'd still like esbuild to cascade tree shaking though - I believe other cases that aren't as weird as "calling a pure function for no reason" would benefit from this.

KTibow avatar May 24 '25 18:05 KTibow