Liking cljdoc? Tell your friends :D

Tree-shaking / dead-code elimination of squint output

Notes on how well squint's JS output tree-shakes under a bundler (esbuild), where it fails, and how to fix it. Benchmark: examples/replicant.

All byte counts below are esbuild --bundle --minify --format=esm output.

Summary

  1. vite build already emits tree-shakeable static imports. The committed examples/replicant/js/*.js is stale dev output and is NOT representative.
  2. There is a fixed ~5.4KB floor that every squint import drags in, caused by (a) two top-level protocol-mutation statements and (b) collection classes with computed Symbol-key members that esbuild cannot eliminate.
  3. Symbols can be kept and still get DCE by splitting the Symbol-keyed classes into their own ES modules, relying on package-level sideEffects: false.

Static vs dynamic import

The compiler emits a static namespace import for normal compilation and a dynamic await import() only in REPL mode (compiler.cljc compile*, repl?). The vite plugin sets repl: !isBuild (vite.js), so:

  • vite build -> import * as squint_core from 'squint-cljs/core.js' -> tree-shakeable.
  • dev / REPL -> var squint_core = await import('squint-cljs/core.js') -> NOT tree-shakeable (whole module retained). Fine because dev output is not bundled for production.

Core-only cost by import style:

import stylebytes
await import() (dev/REPL)35,873
import * as squint_core (build)5,886
named { atom, swap_BANG_, ... }5,887

esbuild tree-shakes static namespace import and named import equally; weaker bundlers may do better with named imports.

The committed examples/replicant/js/ is dynamic-import dev output. It makes squint look non-tree-shakeable. Regenerate or gitignore it.

The ~5.4KB floor

A no-op import drags in a large chain:

importbytes
identity (literally return x)5,377
inc / str / deref~5,38x
atom5,752
map,filter,reduce,assoc,get,conj,vec8,800

Importing identity retains: dequal, get, LazyIterable, Cons, List, SortedSet, sort, compare. identity references none of these. Two causes.

Cause 1: top-level protocol mutations (~0.9KB)

Two top-level statements in src/squint/core.js:

LazyIterable.prototype[IIterable] = true;   // ~line 907
concat.squint$lang$variadic = (colls) => { ... }; // the apply hint

These are side-effecting assignments that reference their target symbols, so esbuild keeps them and transitively whatever they reference. Removing both drops the identity floor 5,377 -> 4,509. sideEffects: false in package.json does NOT shed them (verified via real package resolution).

Cause 2: computed Symbol-key class members (~4.5KB)

esbuild will not dead-code-eliminate a class that has a computed member key unless the key is a literal string/number. Confirmed:

class C { [SYM](){} }          // unused -> KEPT  (SYM is a const Symbol)
class C { [Symbol("x")](){} }  // unused -> KEPT  (inline Symbol call)
class C { [Symbol.for("x")](){} }            // KEPT
class C { [/* @__PURE__ */ Symbol("x")](){} }// KEPT (annotation ignored here)
class C { [Symbol.iterator](){} }            // KEPT (even well-known symbol)
class C { ["squint$IIterable"](){} }         // unused -> DROPPED (literal!)
class C { plainMethod(){} }                  // unused -> DROPPED

A non-literal computed key marks the class definition as possibly side-effecting (the key could be a getter), so esbuild retains the class. The post-class form C.prototype[SYM] = ... does not help: the assignment statement references C and keeps it.

squint's collection classes (LazyIterable, Cons, List, SortedSet, ...) all carry protocol methods under computed Symbol keys ([IIterable], [Symbol.iterator], ...). Each is therefore retained unconditionally and drags in what its methods reference (dequal, get, sort, compare). That is the floor.

Implemented

The floor was removed (branch dce-tree-shaking). Everything stays in the single core.js; no new public symbols, no new modules. Two tiny helpers, marked @__NO_SIDE_EFFECTS__ so the annotation lives at the definition (no per-call /* @__PURE__ */):

  • defclass(c) returns its class arg. Used as const C = defclass(\nclass C { ... }) with the class body kept flush-left (no re-indent). A bare class C { [Symbol.iterator](){} } is retained unconditionally (computed key looks side-effecting); passing it through a no-side-effects call lets a bundler drop it when C is unused.
  • withApply(f, applyFn) sets f.squint$lang$variadic = applyFn and returns f. Used as export const concat = withApply(fn, applyFn). A bare top-level concat.squint$lang$variadic = ... is a side effect bundlers never drop; routing it through the no-side-effects helper lets concat drop when unused. Scales to any number of variadic-apply fns, no per-fn module.

@__NO_SIDE_EFFECTS__ needs esbuild 0.18+ or rollup 3.13+ (vite 5-8 ship rollup 4, so vite dev=esbuild and prod=rollup both honor it). squint's own devDep esbuild was bumped 0.14 -> 0.28. On a bundler that ignores the annotation, DCE degrades silently (the floor returns); nothing breaks.

Three techniques remove the floor:

  1. LazyIterable, Cons and SortedSet (the computed-[Symbol.iterator] classes) are each defclass(class ...), so a bundler drops the ones an app never constructs.
  2. Brand-based dispatch. typeConst, dequal and isVectorArray read an instance brand (TYPE_TAG, set in each constructor) instead of instanceof List/LazyIterable/SortedSet, so the central path that every app pulls does not reference the classes. The remaining instanceof checks live inside seq fns that legitimately use a class, so they only pull it when that fn is used.
  3. concat uses withApply (above).

The public export surface is byte-for-byte identical to the previous release (verified by diffing the export lines); the helpers and TYPE_TAG are unexported module-locals.

The apply hint (squint$lang$variadic, set by withApply and by variadic/ multi-arity codegen) is a string property, not a symbol or export. See doc/ai/adr/0001-variadic-fn-native-rest.md for the variadic/multi-arity codegen and lazy apply that build on this.

Results, esbuild 0.28 minified, importing from squint-cljs/core.js:

slicebeforeaftersaved
identity5,3773999%
atom,deref,reset!,swap!5,8871,30978%
map,filter,reduce,vec,range7,4625,28729%
str,println,pr-str7,5765,17431%
14-fn realistic mix9,5888,7508%

The win scales with how much of core an app does NOT use. Apps that use lazy seqs (map/filter/...) still pull LazyIterable, which is correct. The examples/replicant production build is unchanged (~61KB): it uses seqs and is dominated by the replicant library, not core. A reagami hello app (input + button) drops ~5% gzip (5.83 -> 5.55 kB).

Verified in a real browser: an importmap that maps only squint-cljs/core.js still works unchanged. core.js stays a single module with no new sibling imports, so importmap users see no extra fetches and no breakage.

Two non-obvious blockers, and why the wrappers work

  • A computed (non-literal) class member key is never tree-shaken. class C { [SYM](){} } is retained even when unused, with any non-literal key (a const Symbol, Symbol("x"), Symbol.for(...), even the well-known Symbol.iterator); the key could be a getter, so the definition is treated as side-effecting. A literal key (["lit$key"]) DOES drop. defclass(() => class ...) sidesteps this: the class expression is built inside a call, and the /* @__PURE__ */ marks the call droppable.
  • A top-level property mutation (fn[SYM] = ...) is never dropped, even with sideEffects: false. withApply defers the mutation into a /* @__PURE__ */ call so it drops with the fn.

Both wrappers keep Symbols. Symbols are the right tool for collision-free protocol dispatch; the problem was purely how bundlers treat computed keys and top-level mutations, not Symbols at runtime.

Benchmarks

  • examples/replicant npm run build: ~61KB / 18.8KB gzip, unchanged. It uses seqs and is dominated by the replicant library, so core's slice is already small.
  • reagami hello app (input + button): 5.83 -> 5.55 kB gzip (~5%). Seq-using.
  • The win concentrates in small / non-seq apps, where the old ~5.4KB fixed floor was a large fraction of total size (now ~0.2KB for an atom-only app).

Other opportunities (not done)

  • Emit named imports of only the used vars instead of import * as for more robust tree-shaking on weaker bundlers (webpack, old rollup). esbuild is already equal.
  • deftype emits the constructor and its prototype assignments as top-level statements, so a type an app never constructs is retained along with the protocol symbols and method bodies it references. This is the same blocker withApply solves, in emitted user code rather than in core. Measured on a module defining IShape with -area and -name plus one deftype, bundled from an entry importing only -area: 711B. Wrapping the constructor and its prototype assignments in a single /* @__PURE__ */ IIFE that returns the constructor drops it to 464B, with shapes/-name, the shapes/IShape marker and both method bodies gone. Prototype methods stay shared, so there is no per-instance cost, unlike branding in the constructor as Atom does. Not done. It still needs to cover multi-protocol types, Object methods and defrecord, and only esbuild 0.28 was checked. extend-type on an existing type has no construction site to wrap, so those assignments stay top-level.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close