Semantic Diff

#9284 Bind elements and inlined arguments read from assigned variables

2 commits

e941d27298f5f7dcbc9a42cce0d5d7b349d27f7d..pr-9284

f0c8e7ae4 · mununki · Nov 09, 2026, 15:06 UTC

Bind elements and inlined arguments read from assigned variables

Why

A local ref that does not escape compiles to a JavaScript variable that is assigned with =, and a @val external reads a JS global that JS code may assign. Two JS passes replace a read of a value taken from such a variable with a read of the variable itself, which gives the value of a later assignment (#9227). Js_pass_flatten_and_mark_dead replaces t[i] and t.field of an immutable tuple, record or constructor with two or more elements by the element when it is a variable, and binds the other elements before the block, so such a variable was also read after the call of a later element. Js_pass_tailcall_inline replaces a parameter of a function inlined at a tail call by its variable argument; since #8617 turns a ref captured by a closure into a variable too, that argument can be a ref that the body assigns. Js_pass_record_rest then read a field of such an argument after the assignment from the rest pattern's destructured fields. Both passes now bind an element or argument that is a variable some Lassign assigns (Lam_stats.assignable) or a JS global, as they bind other expressions. That is the rule Lam_compile.delay_safe applies to a variable so as not to delay reading it, now shared by the three as Js_analyzer.var_may_change; other variables are still read in place.

modifiedJS tuple and record elements

type point = {x: int, label: string} let f = y => {  let x = ref(1)  let t = (x.contents, y)  x := 2  let (a, b) = t  a + b} let g = label => {  let x = ref(1)  let p = {x: x.contents, label}  x := 2  (p.x, p.label)}
js
Before f(10) returns 12, g("p") returns [2, "p"]
After f(10) returns 11, g("p") returns [1, "p"]; y and label are still read in place
function f(y) {function f(y) { let x = 1; let x = 1; let t_0 = x; x = 2; x = 2; return x + y | 0; return t_0 + y | 0;}} function g(label) {function g(label) { let x = 1; let x = 1; let p_x = x; x = 2; x = 2; return [ return [ x, p_x, label label ]; ];}}

modifiedJS tuple elements read in closures

let snapshots = () => {  let i = ref(0)  let out = []  while i.contents < 3 {    let pair = (i.contents, "x")    out->Array.push(() => {      let (n, _) = pair      n    })    i := i.contents + 1  }  out->Array.map(get => get())}
js
Before snapshots() returns [3, 3, 3]
After snapshots() returns [0, 1, 2]
while (i < 3) {while (i < 3) { out.push(() => i); let pair_0 = i; out.push(() => pair_0); i = i + 1 | 0; i = i + 1 | 0;};};

modifiedJS an element before a call

let f = () => {  let x = ref(1)  let bump = () => {    x := x.contents + 1    x.contents * 10  }  let t = (x.contents, bump())  let (a, b) = t  a + b}
js
Before f() returns 22, x is read after bump()
After f() returns 21
let t_0 = x;let t_1 = bump();let t_1 = bump();return x + t_1 | 0;return t_0 + t_1 | 0;

modifiedJS arguments of a function inlined at a tail call

let run = step => {  let count = ref(0)  let bump = (before, by) => {    count := count.contents + by    (before, count.contents)  }  bump(count.contents, step)}
js
Before run(3) returns [3, 3]
After run(3) returns [0, 3]; step still replaces by
function run(step) {function run(step) { let count = 0; let count = 0; let before = count; count = count + step | 0; count = count + step | 0; return [ return [ count, before, count count ]; ];}}

modifiedJS a rest pattern parameter of an inlined function

type config = {name: string, version: string, debug: bool}type restConfig = {version: string, debug: bool} let replace = () => {  let c = ref({name: "a", version: "1", debug: true})  let k = ({name: _, ...restConfig as rest}: config) => {    c := {name: "b", version: "2", debug: false}    (rest.version, c.contents.name)  }  k(c.contents)}
js
Before replace() returns ["1", "a"]; Js_pass_record_rest read c.name from the rest pattern
After replace() returns ["1", "b"]
let {name, ...rest} = c; let param = c;let {name: __unused0, ...rest} = param;c = {c = { name: "b", name: "b", version: "2", version: "2", debug: false debug: false};};return [return [ rest.version, rest.version, name c.name];];

modifiedJS JS globals

@val external counter: int = "counter"@val external bump: unit => unit = "bump" let saved = () => {  let t = (counter, "b")  bump()  let (a, _) = t  a} let report = () => {  let show = before => {    bump()    (before, counter)  }  show(counter)}
js
Before with counter 1 and bump incrementing it, saved() returns 2, then report() returns [3, 3]
After saved() returns 1, then report() returns [2, 3]
function saved() {function saved() { let t_0 = counter; bump(); bump(); return counter; return t_0;}} function report() {function report() { let before = counter; bump(); bump(); return [ return [ counter, before, counter counter ]; ];}}

modifiedJS correct programs that get a binding

type item = {id: int, name: string} let single = n => {  let id = ref(0)  let out = []  for _ in 1 to n {    let item = {id: id.contents, name: "x"}    out->Array.push(item)    id := id.contents + 1  }  out} let afterLoop = () => {  let i = ref(0)  while i.contents < 10 {    i := i.contents + 1  }  let report = n => {    Console.log("report")    Console.log(n)    n * 2  }  report(i.contents)}
js
Before no assignment comes between the element or argument and its read
After the same results, with a binding
let item_id = id;let item = {let item = { id: id, id: item_id, name: "x" name: "x"};};…… let n = i;console.log("report");console.log("report");console.log(i);return (i << 1);console.log(n);return (n << 1);

Review

  • Why Lam_stats.assignable and JS globals, and not every variable that the JS assigns? Binding every variable that the JS assigns changed inline_condition_with_pattern_matching.mjs and mario_game.mjs, whose output was correct. The JS assigns switch results, static-catch bindings, exit variables and the temporaries of && and || only before reading them, and reads the parameter _x of a tail-recursive loop only through the let x = _x of each iteration. A for index that Lam_compile.compile_for declares before the loop (let i = e; for (; i <= n; ++i)) changes between iterations, so a closure that captures it reads the last value, with or without these passes; that is a bug of compile_for, left to a separate change. With Js_analyzer.var_may_change no checked-in output changes, and the set is already computed, so the fix adds no traversal.
  • Why bind the element instead of keeping t[i]? Keeping t[i] would keep the block alive and would still read the element after the call of a later element, which is bound before the block. With the binding the block is still dead when every use is a read like t[i], as with other expressions. A block that stays alive, like a variant matched with typeof, keeps the binding next to it (let v_0 = x; let v = {TAG: "Pair", _0: v_0, ...}), as it does for other expressions.
  • Does a correct program get longer output? Yes, when the element or argument is such a variable but no assignment comes between, as in the last block: an element of a block that escapes gets a binding, as an element that is a call already does, and so does an argument that the inlined body does not assign. Telling these cases apart needs to know where the variable is assigned, closures included, which is a new analysis; no checked-in output changes.
  • Do other JS passes substitute the same way? Js_pass_record_rest replaces source.label after a record rest pattern by the destructured field, and a rest pattern rejects mutable fields. Its source is the match variable Lambda binds to the matched value or a parameter, and before this commit Js_pass_tailcall_inline could replace such a parameter by an assigned variable (the rest pattern block); with the argument bound, nothing assigns the source. Js_pass_scope, Js_pass_get_used and Js_shake only analyze or remove code, and Js_pass_flatten and Js_pass_external_shadow only restructure statements or qualify globals.
  • Does cross-module inlining add assigned variables that the set misses? With the experimental -bs-cross-module-opt, Lam_compile adds the variables an inlined body assigns only to the stats it compiles that body with; the functions with an assigned ref that were tried fail earlier, in Lam_dce, as they did before this commit, so this path is left as it is.
5b097c02a · mununki · Nov 10, 2026, 17:30 UTC

Protect JS globals from the bindings that later passes add

Why

Js_pass_external_shadow reads a JS global x through globalThis.x where a binding printed as x is in scope. It ran before Js_pass_tailcall_inline and Js_pass_flatten_and_mark_dead, which add bindings of their own: the elements of a block and the arguments of an inlined function. One of those could capture the global it reads. A top-level tuple whose element reads a global named like the element's variable, t_0, compiled to let t_0 = t_0;, which throws a ReferenceError when the module loads. The previous commit binds such an element; before it, an element that is an expression did the same, as let t_0 = t_0 + 1 | 0;. Inside a function the printer renames the binding, since it reserves the names the function refers to. The pass now runs after the two passes. They put the bindings they add in a Block statement, which is printed without braces, so the pass also collects the declarations of a nested Block.

modifiedJS global named like the variable of an element

@val external tZero: int = "t_0"let setTZero: int => unit = %raw(`n => { globalThis.t_0 = n }`)let () = setTZero(1)%%private(let t = (tZero, "b"))let () = setTZero(2) let globalNamedLikeElement = () => {  let (a, _) = t  (a, tZero)}
js
Before
After
setTZero(1);setTZero(1); let t_0 = t_0;let t_0 = globalThis.t_0; setTZero(2);setTZero(2); function globalNamedLikeElement() {function globalNamedLikeElement() { return [ return [ t_0, t_0, t_0 globalThis.t_0 ]; ];}}

Review

  • Does running the pass later change other output? No checked-in output changes besides the new test. The pass decides by the name a binding is printed with before the printer renames it, so where the printer would rename a binding inside a function, the global is now read through globalThis as well.
  • Why look into nested Block statements? At its old place the pass ran right after Js_pass_flatten, which splices nested blocks into their parent, so a block's declarations were all at its top level. Js_pass_flatten_and_mark_dead returns the bindings it adds as a Block, and Js_dump prints a Block without braces.
  • Why no changelog entry? A global named like a generated variable, such as t_0, is rare, and the entry for the previous commit covers the bindings this protects.