Skip to content

ES module output: const and let instead of var in functions - #14

Merged
k7d merged 3 commits into
mainfrom
esm-const
Oct 30, 2026
Merged

k7d merged 3 commits into
mainfrom
esm-const

Conversation

@k7d

@k7d k7d commented Oct 29, 2026

Copy link
Copy Markdown
Member

In ES module output, locals inside functions are now declared with const, or with let when they're reassigned, instead of var. Module-level vars stay var, since hot reloads and defonce assign them. Classic output is unchanged.

Motivation: tools that analyze the compiled JavaScript reject functions that use var. React Compiler is the main one (Handle var kinds in VariableDeclaration). Block-scoped declarations are also simply the modern default.

Changes (ES module output only)

  • Declarations: let bindings, letfn, recur temporaries, variadic delegates and self__ become const. Loop locals, case results, variadic rest args and assigned bindings (assign-tails) become let.
  • Hoisted locals: locals hoisted out of expressions (emit-let) are declared with let at the start of the function. Before, they were a var at the end, which relied on hoisting. The body is written to a buffer until the hoisted locals are known; the declaration sits on the line of the opening brace, so source maps are unaffected.
  • case: each clause is wrapped in a {} block, since a switch shares one scope and two clauses declaring the same const would clash.
  • self__ parameter: when a parameter is already named self__ (the IFn methods of multi-arity fns), it's assigned instead of redeclared.
  • cljs.proxy: dropped its (js* "var __ctor"). It was only needed for var semantics; __ctor is a let binding.
  • unsafe-cast: returns its value unchanged in ES module output. The cast is only a Closure type annotation, and assigning to the argument fails when it's a const local.

React Compiler

With babel-plugin-react-compiler 1.0 (compilationMode: 'all') on a test namespace, components using let, destructuring, case, loop and hooks now compile; before, any function with a local was rejected. Components using for are still rejected, because the iterator fn calls itself by its function-expression name. That's a follow-up. Components still need to be marked or detected as components; this PR doesn't do that.

Performance

  • Runtime: the 185 cljs.core benchmarks (release build bundled with rolldown, Node 26, 5 interleaved runs each, medians) are neutral: total +0.7%, geometric mean of the ratios for benchmarks taking at least 10 ms is 1.014. One JS-array .map benchmark measured about 2x on the branch, but its timings swing between about 18 and 40 ms on both builds, so it's probably noise; not confirmed.
  • Size: the minified benchmark bundle is +0.2% raw and −0.9% gzipped.
  • Not measured yet: the Whimsical app's performance suite.

Tests

  • script/test-esm: 0 failures, 0 errors. It includes a new build test that the output has no var in functions, that loop and hoisted locals are let and the rest const, that case clauses are blocks, and that the output runs correctly.
  • Compiler tests (clojure -M:closure:compiler.test:compiler.test.run): 0 failures, 0 errors. Classic output is unchanged.

@k7d

k7d commented Oct 29, 2026

Copy link
Copy Markdown
Member Author

Fixes from review (a371dd8, ee26617):

  • letfn bindings collided under const. Two letfns binding the same name in one scope (in a fn, or at a namespace's top level) failed with SyntaxError: Identifier 'h' has already been declared. Outside an IIFE they're now renamed apart, as let's bindings are.
  • A rest param named var_args was declared again. (fn [a & var_args] ...) emitted let var_args = null; in a function whose parameter is var_args. It's now assigned.
  • Nested fn bodies were copied once per nesting level. Fns nested in a fn now write a placeholder for their hoisted locals' declaration, filled in once the outermost fn's body is emitted. Same output; the benchmark namespace compiles in 2.4 s instead of 2.5 s.
  • emit-fn-body's unused 2-arity is removed.

Regression tests added. script/test-esm and the compiler tests pass.

k7d added 3 commits October 30, 2026 09:10
…a rest param named var_args isn't declared again (const and let can't be redeclared)
… locals' declaration, filled in once, instead of copying their bodies at every level
@k7d

k7d commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Restacked on main with #21 merged (312dfa40), force-pushed:

The review-fix merges are folded into the branches' own commits. The code is unchanged apart from #21's changes. Only CHANGELOG.md and the build tests needed conflicts resolved, and each branch's [Next] section now has main's entries plus its own.

On every branch, script/test-esm and the compiler tests pass, including #21's new test-locals-munged-to-the-same-name. On #17 the Node tests pass too, and the react-compiler template works in the dev server (memoized rows).

@k7d
k7d marked this pull request as ready for review October 30, 2026 12:09
@k7d
k7d merged commit adc14e2 into main Oct 30, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant