cljs-dev

borkdude 2026-01-22T15:22:19.167749Z

Q: why is this in the CLJS compiler.cljc?

(when finally
          (assert (not= :const (:op (ana/unwrap-quote finally)))
                  (str "finally block cannot contain constant"))
          (emits "finally {" finally "}"))
It seems there's always a :do in there even when you have 1 expression and this 1 expression can be a constant, and bypasses this warning. So it seems to not do anything really.

dnolen 2026-01-22T17:32:49.223529Z

not familiar or forgot, would have to go through the history

borkdude 2026-01-22T19:28:40.568219Z

@thheller I thought more about your remark about trying to avoid IIEFs with let expr emission etc. I think this would be a significant rewrite of the analyzer + compiler. Consider e.g. this:

(defn foo [x] x)
(foo (let [x 1] (inc x))
;;=>
var x = 1;
var temp1 = x + 1 // result of (inc x)
foo(temp1)
or:
(defn foo [x] x)
(foo (if (odd? 1) (let [x 1] (inc x)) 2))
;;=>
var temp1;
if (odd(1)) {
  var x = 1;
  temp1 = x + 1;
}
else {
  temp1 = 3;
}
foo(temp1);
You'd need to hoist variables to assign expression results but also for recursive expressions:
(defn foo [x] x)
(foo (inc (dec 1)))
;;=>
var temp1;
var temp2;
temp2 = 1 - 1;
temp1 = temp2 + 1;
foo(temp1);
I think it would complicate things a lot? (TIL that this is called A-normal form?)

thheller 2026-01-23T08:05:56.079909Z

If I'm not completely off the entire thing composes, so that no super complicated analyzer passes are needed. it likely won't be optimal and do many more assignments than necessary, but :advanced can take care of that for us. For development it may actually be beneficial because of lots more opportunities for breakpoints in JS debugger

thheller 2026-01-23T13:49:33.323299Z

cljs.core.foo = function cljs$core$foo(bar) {
  let expr_if_121814;
  let expr_do_121815;
  cljs.core.prn.cljs$core$IFn$_invoke$arity$variadic(
    cljs.core.prim_seq.cljs$core$IFn$_invoke$arity$2(
      [new cljs.core.Keyword(null, "foo", "foo", 1268894036)],
      0,
    ),
  );

  expr_do_121815 = bar();
  if (cljs.core.truth_(expr_do_121815)) {
    let expr_do_121816;
    cljs.core.identity(1);

    expr_do_121816 = 1;
    expr_if_121814 = expr_do_121816;
  } else {
    expr_if_121814 = 2;
  }
  var x = expr_if_121814;
  return x;
};
got a basic version that does seem to work for at least the dummy example

thheller 2026-01-23T13:50:25.648609Z

(defn foo [bar]
  (let [x (if (do (prn :foo) (bar)) (do (identity 1) 1) 2)]
    x))

thheller 2026-01-23T13:52:14.070199Z

lots of lots of extra symbols, but no more IIFE in if/do

borkdude 2026-01-23T13:59:49.444009Z

this could probably be optimized: > expr_do_121816 = 1; > expr_if_121814 = expr_do_121816; as a direct assignment to the expr_if one, by forwarding the assigntarget to the do AST instead. With this change in place, are you able to spin up the CLJS node REPL and run the tests? I'd be interested in seeing the change

thheller 2026-01-23T14:17:30.641969Z

doesn't quite work yet. will upload the branch after cleanup

borkdude 2026-01-23T15:23:25.542629Z

I tested if Closure optimizes the IIFEs, it doesn't

(defn ^:async foo []
  (let [x (let [y (await (js/Promise.resolve 1))] y)]
    (js/console.log (inc x))))

(foo)
;;=>
(async function() {
  var $x$jscomp$662$$ = await async function() {
    return await Promise.resolve(1);
  }();
  return console.log($x$jscomp$662$$ + 1);
})();
My test script:
(require '[cljs.build.api :as b]
         '[clojure.java.io :as io])

;; Create test source
(let [src-dir "/tmp/cljs-test/src"
      src-file (io/file src-dir "demo/core.cljs")]
  (.mkdirs (.getParentFile src-file))
  (spit src-file
        "(ns demo.core)

(defn ^:async foo []
  (let [x (let [y (await (js/Promise.resolve 1))] y)]
    (js/console.log (inc x))))

(foo)"))

;; Build by passing the source FILE directly (not using :main)
(doseq [opt [#_:simple :advanced]]
  (b/build "/tmp/cljs-test/src/demo/core.cljs"
           {:output-dir "/tmp/cljs-test/out-adv"
            :output-to (str "/tmp/cljs-test/" (name opt) ".js")
            :optimizations opt
            :pretty-print true
            :pseudo-names true}))

(println (slurp "/tmp/cljs-test/advanced.js"))

thheller 2026-01-23T15:39:08.353389Z

yeah didn't expect it for async fns. I don't think that would be technically correct since the behavior changes.

borkdude 2026-01-23T15:48:16.897929Z

let's see if it does for non async

borkdude 2026-01-23T15:51:02.042159Z

lol, it optimizes this:

(defn foo []
  (let [x (let [y 1] y)]
    (js/console.log (inc x))))

(foo)
to:
console.log(2)
so yes 😂

borkdude 2026-01-23T15:54:21.825709Z

A quickwin would be to check if there's an await happening in the let (or anything else that needs IIFE) and if not, just emit a non-async IIFE.

schadocalex 2026-01-23T16:13:52.456229Z

Yea that's why I said it's already "insanely optimized" :p Ho yes if async is propagating from the fn def it might be a bit too much. Propagation from the await itself to the top should be more than enough for most real-world applications.

borkdude 2026-01-23T16:14:47.546389Z

to the top of what?

schadocalex 2026-01-23T16:15:36.531909Z

async context (fn def)

schadocalex 2026-01-23T16:15:49.263099Z

literally what you said

borkdude 2026-01-23T16:16:06.060959Z

I can't parse your last utterance, it seems you said: if A, that's bad. A is more than enough.

schadocalex 2026-01-23T16:18:13.862629Z

from the async (fn def) to the bottom (all IIFE recursively) => your current implementation, if I understood correctly from the await (js-await) to the top (fn def) => will await only IIFEs that have await calls under

borkdude 2026-01-23T16:19:15.655669Z

I don't understand why you put it like that, but yeah, the parts after => make sense to me

schadocalex 2026-01-23T16:21:30.589049Z

it was from an AST point of view (top/bottom of the tree), sorry it that wasn't clear

borkdude 2026-01-23T16:23:20.074929Z

only the scope (let ... (await )) matters, not, all the way up to the function body

schadocalex 2026-01-23T16:25:14.201069Z

I should take a look at your current implementation instead of trying to understand what you've done from your words.

borkdude 2026-01-23T16:26:49.338139Z

Words are difficult I guess ;). What I mean: for the optimization to turn an async IIFE originated from a let into a non-async one, we should track await usages in the let (bindings and body). This is what I mean with: only that scope matters. Mentioning "top of the function" confuses things for me.

schadocalex 2026-01-23T16:41:43.256549Z

I thought it would also apply for others IIFE too (do, case, ... idk all of them)

thheller 2026-01-23T16:42:07.498939Z

there is so much weird code in cljs.analyzer. every time I look at it I want to clean it up, but that just distracts me from what I'm actually trying to do 😛

thheller 2026-01-23T16:44:35.377639Z

this is going to require a lot more work than I anticipated, so will continue some other time. not sure its the correct path either, so threw away the branch. failed quite hard at compiling cljs.core, so not all that useful.

borkdude 2026-01-23T16:49:29.049729Z

the core.async go macro does SSA which is even stricter than ANF - we could maybe just use that? ;)

thheller 2026-01-23T16:53:41.797979Z

well the idea works. I think at least, just not the shortcut I was trying to take 😉

thheller 2026-01-23T16:54:03.273259Z

from what I can tell the concept of an expression needs to be removed everywhere. or rather rewritten always.

borkdude 2026-01-23T16:55:15.518989Z

yes, all expressions need to be transformed to assignments of simple values. ANF

borkdude 2026-01-23T16:56:17.251329Z

perhaps a good path to write a clj -> webassembly compiler ;)

schadocalex 2026-01-23T16:56:36.190319Z

then it would be possible to emit block statements yes. But still a lot of work to do in analyzer for converting to ANF

borkdude 2026-01-23T16:56:39.199019Z

(I haven't looked at webassembly, let me not side-track this discussion with it, sorry)

borkdude 2026-01-23T16:56:56.606189Z

if only if JS had block expressions, our problems would be solved

thheller 2026-01-23T16:58:28.099769Z

that would certainly make things much easier yeah

borkdude 2026-01-23T16:59:16.953739Z

there are these TC39 do expression proposal babel plugins: they emit IIFEs

borkdude 2026-01-23T16:59:25.742019Z

I wonder how they would emit async/await, let me check that

borkdude 2026-01-23T17:03:02.783119Z

async function foo() {
    let x = do { 
      foo();
      let y = do { await bar() };
    };
}
=>
async function foo() {
  let x = await async function () {
    return foo();
    let y = await bar();
  }();
}

thheller 2026-01-23T17:04:06.171379Z

that seems incorrect? why is there a return?

borkdude 2026-01-23T17:06:49.533289Z

I think it's a bug (and it does even feel familiar to me since it happened to me in squint as well lol) They do optimize this:

async function foo() {
    let x = do { 
      foo();
      let y = do { bar() };
    };
}
to non-async IIFE

borkdude 2026-01-23T17:07:09.076819Z

they also optimize do that doesn't introduce any locals to comma-expressions, but that's a very specific optimization

schadocalex 2026-01-23T17:07:57.885129Z

https://github.com/babel/babel/blob/main/packages/babel-plugin-proposal-do-expressions/src/index.ts implementation doesn't seem to be straightforward

borkdude 2026-01-23T17:09:11.306749Z

JS is an imperative language with control flow, which makes stuff even more complicated than CLJS. We live in a relatively simple language

👍 1
schadocalex 2026-01-23T17:18:53.496679Z

if you enable https://babeljs.io/docs/babel-preset-env#forcealltransforms it gives different results than IIFEs

borkdude 2026-01-23T17:20:51.477119Z

I think it's because it will transform async/await into some pre ES6 stuff which is way more complicated

schadocalex 2026-01-23T17:25:28.900409Z

for async yes but IIFEs sometimes got transformed to this syntax:

let x = do {
    let y = 40;
    let z = 2;
    foo(y + z);
};
=>
var y, z;
var x = (y = 40, z = 2, foo(y + z));

borkdude 2026-01-23T17:27:23.850999Z

that's a comma expression, mentioned it above already

borkdude 2026-01-23T17:27:37.422999Z

ah you mean, when you introduce locals

borkdude 2026-01-23T17:27:52.218859Z

yeah, I guess it hoists.

borkdude 2026-01-23T17:28:12.688039Z

hmm, which specific setting is this? could be interesting

schadocalex 2026-01-23T17:30:56.486929Z

and yes it's hoists, adding some name conflicts confirm

borkdude 2026-01-23T19:36:24.527889Z

$ clj -M:cljs-repl -m cljs.main -re node
ClojureScript 0.0.1213971316
cljs.user=> (defn ^:async foo [] (let [x 1 y (let [x 2] (inc x))] (await (+ x y))))
#'cljs.user/foo
cljs.user=> (str foo)
"async function cljs$user$foo(){\nvar x = (1);\nvar y = (function (){var x__$1 = (2);\nreturn (x__$1 + (1));\n})();\nreturn (await (x + y));\n}"

borkdude 2026-01-23T19:36:49.561509Z

this avoids async for: var y = (function (){var x__$1 = (2);\nreturn (x__$1 + (1));\n})()

borkdude 2026-01-23T19:36:57.243939Z

which can then be optimized by Closure

borkdude 2026-01-23T19:59:38.839029Z

After this optimization:

(defn ^:async foo []
  (let [x (let [y 1] y)]
    (await 1)
    (js/console.log (inc x))))

(foo)
=>
(async function() {
  await 1;
  return console.log(2);
})();

👍 2
thheller 2026-01-23T20:01:00.140049Z

although a more likely case will actually do an await somewhere "deeper", so with IIFE

borkdude 2026-01-23T20:01:29.007459Z

true, but many sub-expressions could be clean

borkdude 2026-01-23T20:02:47.585359Z

I implemented it like this: https://github.com/clojure/clojurescript/commit/ae967ca49f9941f560b5a4a25e5ac4c20c46dbd3

borkdude 2026-01-23T20:03:00.554839Z

(easy to extend to the other IIFE-producing forms)

borkdude 2026-01-23T20:04:16.154289Z

I thought I undid the whitespaces changes. If I'm going to add this to the patch, I will. This is a different branch just in case

schadocalex 2026-01-23T21:04:10.444389Z

nice!

schadocalex 2026-01-22T20:06:34.436169Z

I'd be very surprised too see any benchmark giving IIFEs significantly slower than any "exploded/inlined" version of the same code. They are already insanely optimized. It might add some optimizations too as they have a smaller local scope, better type consistency between calls, and all the craziest micro optimizations you could think of. Plus it adds more complexity on the upper function (and so can break some optimizations too). Just a guess tho.

➕ 1
thheller 2026-01-22T20:45:06.140519Z

Its been a long time since I looked at the code, but I don't think its going to be that hard. We already do some of it in the invoke case where IFn is considered and the arguments are bound first.

thheller 2026-01-22T20:49:05.810819Z

I would be surprised if it isn't faster, especially in the context of async/await/generators. let alone all the extra code being saved. :advanced does remove some IIFEs but not all.

thheller 2026-01-22T20:51:21.525479Z

I might have some time next month to dig into this a bit, but I have been meaning to do that for years, so we'll see I guess.

borkdude 2026-01-22T20:53:12.744589Z

@thheller Do you mean this thing in parse-invoke*?

(analyze env
          `(let [~@bindings]
             ~(with-meta
               `(~(analyzed (if bind-f-expr? f-sym f))
                 ~@(if bind-args? arg-syms args))
               {:tag tag})))

schadocalex 2026-01-22T20:56:57.315879Z

I might miss something, but how a function call can slow down any async/await code that is extremely slow by ending the current microtask anyway?

thheller 2026-01-22T20:59:58.267559Z

the issue is creating nested IIFEs "get infected" and turn async (and add another await), which I'm fairly certain creates an entire new promise/microtask, which just isn't needed

thheller 2026-01-22T21:04:46.304169Z

totally makes sense to do some basic benchmarks to see if it actually matters, but my instinct says yes.

thheller 2026-01-22T21:05:22.209319Z

also don't want to be doing the same stuff for generators, would be nice if it just worked

borkdude 2026-01-22T21:11:01.889709Z

not sure what you mean with generators

thheller 2026-01-22T21:12:22.297199Z

never trust any benchmark that used eval. that is always unoptimized code

schadocalex 2026-01-22T21:12:40.917559Z

yea ok for nested code blocks, any additional await costs a lot

borkdude 2026-01-22T21:17:48.223199Z

I generated a benchmark from the above code in pure JS. for iters = 10000000:

$ node /tmp/foo.mjs
bar (flat async): 615.81 ms
foo (extra async IIFE): 927.87 ms
foo (extra async IIFE): 889.02 ms
bar (flat async): 580.44 ms
So yeah, it does cost something

👍 2
schadocalex 2026-01-22T21:21:34.048469Z

would also be interesting to see without async?

thheller 2026-01-22T21:22:12.501539Z

as for generators I mean that it just works out of the box with no modifications in the compiler. your branch has a lot of (when async ...) don't want to be adding (when generator ...) as well 😛

borkdude 2026-01-22T21:22:46.693719Z

you mean, with A-normal form in place

thheller 2026-01-22T21:22:51.971819Z

assuming all of this is actually doable, but I see no reason why it wouldn't be

thheller 2026-01-22T21:22:55.715529Z

yes.

borkdude 2026-01-22T21:24:34.132669Z

without async/promise:

$ node /tmp/foo.mjs
bar (flat async): 297.14 ms
foo (extra async IIFE): 292.80 ms
foo (extra async IIFE): 282.46 ms
bar (flat async): 283.65 ms

👍 1
schadocalex 2026-01-22T21:27:25.635559Z

would it be easier to implements variables scopes as blocks instead of hoist variables?

var foo = async function () {
    const x1 = await (async () => {
        const x2 = await Promise.resolve(3);
        return x2 + 1;
    })();
    return x1 + 1;
};
=>
var foo = async function () {
    let x1;
    {
        const x2 = await Promise.resolve(3);
        x1 = x2 + 1;
    }
    return x1 + 1;
};

borkdude 2026-01-22T21:28:21.886529Z

that's what we are discussing right

thheller 2026-01-22T21:28:53.117409Z

pretty sure that extra block does nothing useful, but could be done

schadocalex 2026-01-22T21:29:29.499689Z

(yes i got back to original subject as for async it can be optimized)

borkdude 2026-01-22T21:29:44.075419Z

oh that block. yeah, it might actually be useful to some optimizer that sees that x2 is out of scope or so

schadocalex 2026-01-22T21:32:11.867099Z

it might only change how the code is emitted, but I don't really know how cljs compiler work

borkdude 2026-01-22T21:33:04.784809Z

I actually tried it today but it's not sufficient to just change the emitter I think (or you'll have to modify the envs recursively of existing AST nodes, which is a sign that it's better done in the analyzer probably)

👍 1
thheller 2026-01-22T21:33:50.877079Z

definitely needs support from the analyzer side yeah

thheller 2026-01-22T21:34:42.268079Z

IMHO the emitter shouldn't do any "analysis" work anyway. just leftovers from before the separation for the cases it still does.

borkdude 2026-01-22T21:35:13.775609Z

makes sense

borkdude 2026-01-22T21:39:07.800769Z

to hoist variables, we do need to always have a "statement" boundary somewhere and collect all the assigment targets there. This is more complicated than it seems due to nested expressions, etc. It's doable but you need analyze stuff more than once or make it an analyzer pass even?

borkdude 2026-01-22T21:39:35.460039Z

or you could pass along an atom and update it along the way

borkdude 2026-01-22T21:41:59.466889Z

btw, I also benchmarked the old core.async vs the async/await one and async/await based go macro was slower