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.
vite build already emits tree-shakeable static imports. The committed
examples/replicant/js/*.js is stale dev output and is NOT representative.sideEffects: false.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.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 style | bytes |
|---|---|
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.
A no-op import drags in a large chain:
| import | bytes |
|---|---|
identity (literally return x) | 5,377 |
inc / str / deref | ~5,38x |
atom | 5,752 |
| map,filter,reduce,assoc,get,conj,vec | 8,800 |
Importing identity retains: dequal, get, LazyIterable, Cons, List,
SortedSet, sort, compare. identity references none of these. Two causes.
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).
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.
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:
LazyIterable, Cons and SortedSet (the computed-[Symbol.iterator]
classes) are each defclass(class ...), so a bundler drops the ones an app
never constructs.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.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:
| slice | before | after | saved |
|---|---|---|---|
identity | 5,377 | 39 | 99% |
atom,deref,reset!,swap! | 5,887 | 1,309 | 78% |
map,filter,reduce,vec,range | 7,462 | 5,287 | 29% |
str,println,pr-str | 7,576 | 5,174 | 31% |
| 14-fn realistic mix | 9,588 | 8,750 | 8% |
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.
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.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.
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.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
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |