Semantic Diff

#9159 Evaluate arguments left to right when a later one compiles to statements

1 commit

7928f376da13dd10fde7b171522cc1ab01f5d18a..pr-9159

d52e88ff0 · cknitt + Claude · Nov 01, 2026, 06:23 UTC

Evaluate arguments left to right when a later one compiles to statements

Why

The statements of an argument were emitted before the whole call or literal, so they ran before the arguments to their left, which could read values the statements had already changed (#9154).

modifiedJS argument order

let count = ref(0) let pair = (count.contents, {  count := count.contents + 1  let id = count.contents  `item-${Int.toString(id)}`})
js
Before pair is [1, "item-1"]
After pair is [0, "item-1"]
let tmp = count.contents; count.contents = count.contents + 1 | 0;count.contents = count.contents + 1 | 0; let id = count.contents;let id = count.contents; let pair_0 = count.contents; let pair_1 = `item-${id.toString()}`;let pair_1 = `item-${id.toString()}`;

modifiedJS called fields

let r = o.f({  o.f = x => x + 100  let y = Int.fromString("1")->Option.getOrThrow  y * 2})
js
Before the new function was called
After the function is read before the arguments
let tmp = o.f; o.f = x => x + 100 | 0;o.f = x => x + 100 | 0; let y = Stdlib_Option.getOrThrow(Stdlib_Int.fromString("1"), undefined);let y = Stdlib_Option.getOrThrow(Stdlib_Int.fromString("1"), undefined); let r = o.f((y << 1));let r = tmp((y << 1));

Review

  • Which arguments get a temporary? Only those to the left of an argument with statements, and only when delaying them could change their value. Constants, functions, immutable variables and literals of such values stay in place. Reads of fields, JS globals and assignable variables are held: the statements, or a closure they call, may change them. A literal keeps its shape and only its elements are held, so translations that read their arguments, such as dict rows and @variadic arrays, still see it.
  • How are assignable variables known? They are the targets of Lassign in the final Lambda (Lambda_traverse.assigned_variables), collected once per module into Lam_stats.assignable, and for a body inlined from another module while generating JS.
  • What about method calls? The callee is held like any argument, except a JS method read (Pjs_object_get): there the object is held and the property read stays in the call, so the object is still this.
  • Which other constructs change? JS object writes (obj["k"] = v) go through the same path, so the target is evaluated before the value's statements. The start of a for loop was moved after the statements of its end whenever it had no side effects, which included field reads; it now uses the same check.
  • Does the output change elsewhere? Only where the order was wrong: in the test corpus, one call in bs_array_test.mjs, whose first argument Belt_Array.reverse(x) now runs before the copy in the second. A field read such as init.x in {...init, y: {block}} still gets a temporary, since fields are never delayed.