Semantic Diff

#9231 Read @val globals and synchronous dispatch arguments of externals in place

6 commits

e941d27298f5f7dcbc9a42cce0d5d7b349d27f7d..pr-9231

c90750038 · mununki · Nov 09, 2026, 15:21 UTC

Pass undefined for an optional external argument or JSX key that is None

Why

An optional argument of an external passed as ~x=?e, where e is a call, a dict or array lookup, a conditional or anything else that cannot be read twice to test it first, is compiled to Primitive_option.valFromOption(e). valFromOption reads a property of its argument to find a nested None, so it threw a TypeError when e was None, though its doc comment says that it keeps undefined. #8380 replaced Primitive_option.toUndefined, which tested for undefined before calling valFromOption, on that premise. The same call is emitted for a JSX key, also with -bs-jsx-preserve, for the argument of an inlined function that forwards ~x? to an external, and for an optional @int argument, or @string argument with @as, which switches on it. valFromOption now returns undefined for undefined, as it does for null, which fixes all of them.

modifiedJS Primitive_option.valFromOption

js
Before
After
function valFromOption(x) {function valFromOption(x) { if (x === null || x.BS_PRIVATE_NESTED_SOME_NONE === undefined) { if ((x == null) || x.BS_PRIVATE_NESTED_SOME_NONE === undefined) { return x; return x; } } … …}}

modifiedOutput optional argument that is None

%%raw(`function greet(name) { return name === undefined ? "Hello" : "Hello " + name }`)@val external greet: (~name: string=?, unit) => string = "greet" let greetUser = (names: dict<string>, id) => greet(~name=?names->Dict.get(id), ())Console.log(greetUser(dict{"1": "Ann"}, "2"))
js
Before and After are equal
function greetUser(names, id) {  return greet(Primitive_option.valFromOption(names[id]));}
text
Before
After
TypeError: Cannot read properties of undefined (reading 'BS_PRIVATE_NESTED_SOME_NONE')Hello

Review

  • Why change the runtime and not the compiler? Every path that emits the unchecked call goes through valFromOption: Arg_optional in ocaml_to_js_eff, the JSX key in Lam_compile_primitive, inlined functions, and @int and @as arguments, which switch on its result. get_default_undefined_from_optional returns an expression, so a test in the compiler needs the argument bound to a temporary: a statement for each such argument, through hold_arg for calls and separately for JSX keys, plus temporaries for the arguments to its left (see sequence_arguments). Bringing back toUndefined would undo #8380 for one comparison. This way the generated code stays as it is, and code that 13.0.0-alpha.5 or later already compiled works once the runtime is updated.
  • Does the extra test cost the other callers anything? They pass a value known to be Some: a match on a nested option (Pval_from_option), and the branch after x !== undefined of an @obj assignment or of an optional argument that can be read twice. For them x == null replaces x === null, still one comparison; in a micro-benchmark of 2×10^8 calls the difference is within noise for objects or numbers alone and under 0.3 ns a call for mixed inputs. The runtime grows by one character, and no other compiled module of the runtime, the stdlib or the tests changes.
  • Could an argument that can be read twice now be compiled to valFromOption(x) alone, without x !== undefined ? … : undefined? It could, but that would change much output and call the runtime for None. The next commit does it only for a JS property read, which may run a getter.
308b45686 · mununki · Nov 09, 2026, 15:21 UTC

Evaluate optional @obj arguments in source order and read properties once

Why

An @obj external builds its object as a literal and assigns an optional argument whose presence is not known after it, under if (tmp !== undefined). So that argument was evaluated after the arguments that follow it: make(~size=?size(), ~mode=mode(), ()) called mode() first, and a later argument with side effects could change what the optional argument read, such as a @get getter, a @val global, Dict.get, an array element, a mutable field or a ref. hold_obj_args now binds such an argument to a temporary after its statements, unless evaluating it after the literal gives the same values and effects.

As with the temporaries of #9217, sequence_arguments then evaluates the arguments to the left of the temporary before it, with evaluate_now. That bound an optional argument known to be present as a whole, so that it lost its presence and was assigned after the literal too, and it bound variables and fields of records although defining a temporary as such a value cannot change them. evaluate_now now evaluates the value in an Optional_block and keeps the block, and sequence_arguments leaves an argument that runs no code, a variable, a constant or a field of a record, in place when the statements after it only define variables as such values. A JS property read may run a getter, so it does not count.

An optional argument that is a JS property read, as from a @get external or a @val name with a dot, was read twice, to test it for None and to unwrap it, by a call, a JSX key and an @obj external alike. A getter ran twice, and one that returned a value and then undefined made the call throw a TypeError. Such an argument is now read once: a call and a JSX key give it to Primitive_option.valFromOption, which returns undefined for None since the previous commit, and an @obj external binds it. Js_analyzer.is_okay_to_duplicate ~property_reads:false tells these arguments apart.

modifiedJS optional argument with side effects

@obj external make: (~size: int=?, ~mode: string, unit) => {..} = "" let o = (size, mode) => make(~size=?size(), ~mode=mode(), ())
js
Before
After
function o(size, mode) {function o(size, mode) { let tmp = { let tmp = size(); let tmp$1 = { mode: mode() mode: mode() }; }; let tmp$1 = size(); if (tmp$1 !== undefined) { tmp.size = tmp$1; if (tmp !== undefined) { tmp$1.size = tmp; } } return tmp; return tmp$1;}}

modifiedJS optional argument that a later argument changes

type source@get external name: source => option<string> = "name"@obj external makeName: (~name: string=?, ~mode: string, unit) => {..} = "" let n = (s, rename) => makeName(~name=?name(s), ~mode=rename(s), ())
js
Before
After
function n(s, rename) {function n(s, rename) { let tmp = { let tmp = s.name; let tmp$1 = { mode: rename(s) mode: rename(s) }; }; if (s.name !== undefined) { tmp.name = s.name; if (tmp !== undefined) { tmp$1.name = tmp; } } return tmp; return tmp$1;}}

modifiedJS arguments before a bound optional argument

type props = {color: string, width?: string}@objexternal style: (~color: string=?, ~icon: string=?, ~width: string=?, ~height: string, unit) => {..} = "" let a = (props, height) => style(~color=props.color, ~width=?props.width, ~height=height(), ())let b = (icon, width, height) => style(~icon=icon(), ~width=?width(), ~height=height(), ())
js
Before
After
function a(props, height) {function a(props, height) { let tmp = { let tmp = props.width; let tmp$1 = { color: props.color, color: props.color, height: height() height: height() }; }; if (props.width !== undefined) { tmp.width = props.width; if (tmp !== undefined) { tmp$1.width = tmp; } } return tmp; return tmp$1;}} function b(icon, width, height) {function b(icon, width, height) { let tmp = { icon: icon(), let tmp = icon(); let tmp$1 = width(); let tmp$2 = { icon: tmp, height: height() height: height() }; }; let tmp$1 = width(); if (tmp$1 !== undefined) { if (tmp$1 !== undefined) { tmp.width = tmp$1; tmp$2.width = tmp$1; } } return tmp; return tmp$2;}}

modifiedJS optional argument known to be present before a temporary

type item = {id: int}@obj external mk: (~item: item=?, ~level: @int [#low | @as(10) #high], unit) => {..} = "" let k = async (f, p) => mk(~item=f(), ~level=await p, ())
js
Before
After
async function k(f, p) {async function k(f, p) { let tmp = f(); let tmp = f(); let tmp$1 = await p; let tmp$1 = await p; let tmp$2 = { return { item: tmp, level: (() => { level: (() => { switch (tmp$1) { switch (tmp$1) { … … } } })() })() }; }; if (tmp !== undefined) { tmp$2.item = Primitive_option.valFromOption(tmp); } return tmp$2; }}

modifiedJS optional property read

type source@get external name: source => option<string> = "name"@val external greet: (~name: string=?, unit) => string = "greet"@val external userName: option<string> = "window.userName"@obj external makeName: (~name: string=?, ~mode: string, unit) => {..} = "" let g = s => greet(~name=?name(s), ())let u = () => greet(~name=?userName, ())let m = (o: {"name": option<string>}) => makeName(~name=?o["name"], ~mode="dark", ())
js
Before
After
function g(s) {function g(s) { return greet(s.name !== undefined ? Primitive_option.valFromOption(s.name) : undefined); return greet(Primitive_option.valFromOption(s.name));}} function u() {function u() { return greet(window.userName !== undefined ? Primitive_option.valFromOption(window.userName) : undefined); return greet(Primitive_option.valFromOption(window.userName));}} function m(o) {function m(o) { let tmp = { let tmp = { mode: "dark" mode: "dark" }; }; if (o.name !== undefined) { tmp.name = o.name; let tmp$1 = o.name; if (tmp$1 !== undefined) { tmp.name = tmp$1; } } return tmp; return tmp;}}

modifiedJS temporaries for optional arguments of an @obj external

representation
Before Lam_compile_external_obj.hold_obj_args
After
~x=?f(), ~m=g() ↦ after the literal~x=?p.x, ~m=g() ↦ after the literal~x=?f(), ~m=p.m ↦ after the literal~x=?f(), g() ↦ after the literal~x=?f(), ~m=g() ↦ temporary~x=?p.x, ~m=g() ↦ temporary~x=?f(), ~m=p.m ↦ temporary~x=?f(), g() ↦ temporary~x=?p.x, ~m=p.m ↦ after the literal~x=?p.x, ~m=p.m ↦ after the literal~x=?f(), ~m="dark" ↦ after the literal~x=?f(), ~m="dark" ↦ after the literal~x=?v, ~m=g() ↦ after the literal~x=?v, ~m=g() ↦ after the literal~x=?f(), ~y=?g() ↦ after the literal~x=?f(), ~y=?g() ↦ after the literal
notep.x stands for a field, a global or a lookup, and v for an immutable variable
notep.x stands for a field, a global or a lookup, and v for an immutable variable

modifiedJS arguments before a statement

representation
Before Lam_compile.sequence_arguments
After
r.c, then let tmp = r.x ↦ temporaryr.c, then let tmp = r.x ↦ in placer.c, then let tmp = s.x ↦ temporaryr.c, then let tmp = s.x ↦ temporarys.c, then let tmp = r.x ↦ temporarys.c, then let tmp = r.x ↦ temporaryf(), then let tmp = r.x ↦ temporaryf(), then let tmp = r.x ↦ temporaryr.c, then let tmp = g() ↦ temporaryr.c, then let tmp = g() ↦ temporary~c=f(), then a statement ↦ temporary for the option~c=f(), then a statement ↦ temporary for f(), option in place
noter.c is a variable or a field of a record, s.x a JS property read, and ~c=f() an optional argument known to be present
noter.c is a variable or a field of a record, s.x a JS property read, and ~c=f() an optional argument known to be present

modifiedJS optional arguments read twice

representation
Before Js_analyzer.is_okay_to_duplicate
After Js_analyzer.is_okay_to_duplicate ~property_reads:false
x, M.x, globalName ↦ test, then unwrapx, M.x, globalName ↦ test, then unwrapr.field ↦ test, then unwrapr.field ↦ test, then unwraps.prop, o["prop"], window.x ↦ test, then unwraps.prop, o["prop"], window.x ↦ unwrap oncef(), a[i], c ? x : y ↦ unwrap oncef(), a[i], c ? x : y ↦ unwrap once
noter.field is a field of a record, s.prop a JS property read, such as a @get getter, and window.x a @val name with a dot

Review

  • Which calls keep their output? An @obj call in which nothing that the literal evaluates after the optional argument can change it or be changed by it: constants, immutable variables and functions, reads without side effects when the optional argument has none either, as in make(~x=?p.x, ~m=p.m, ()), and other optional arguments whose presence is not known, which are assigned after it, in order. Variables and fields of records are still tested and unwrapped in place. No checked-in output changes besides the new tests.
  • Which arguments get a temporary they do not need? A later argument is taken to change an optional one unless it is delay_safe or neither has side effects, and the JS IR does not say whether a field is mutable or a call pure. So make(~a=?props.a, ~b=Int.toString(n), ()) binds props.a, as a above binds props.width. A field of a JS object type, o["x"], cannot be told from a getter, so an @obj external binds it even when no later argument can change it, as m above, while a call no longer tests it inline.
  • Why in hold_obj_args and not in hold_arg? Whether the argument has to be bound depends on the arguments after it, which hold_arg does not see, and only an object-creating external evaluates an argument after the ones that follow it. The binding does not depend on the type of the argument, so an optional polymorphic variant argument keeps its place whether or not hold_arg binds a later argument.
  • Why is delay_safe passed to hold_obj_args? It is Lam_compile.delay_safe, which needs the variables the function assigns, and Lam_compile depends on this module.
  • Why change evaluate_now and sequence_arguments here? Before this commit only the temporaries of #9217 for polymorphic variant arguments made sequence_arguments bind the arguments to their left, as in k above, where item lost its place in Object.keys. Binding optional arguments of any type would make that common: without the two changes, b above would assign icon after the literal as well, and a would bind props.color. Both functions are in lam_compile.ml, which #9231 does not change.
  • Why a flag on is_okay_to_duplicate and not another predicate? The two differ only in property reads, and its other callers, min and max of booleans and the object of a Pjs_object_get, keep the default. Js_ast_util.named_expression takes the same flag, which assemble_obj_args passes.
  • Is a @val global still read twice? Yes, a plain one is, like a value of a JS module: only a property read is taken to run a getter, as a @get external is there to do. A @val name with a dot and a global with @scope compile to a property read and are read once.
  • What still runs out of order? The side effects of a unit or @ignore argument are put before the literal or the call, so mk(~a=f(), g()) calls g() first, as before. A getter is taken to have no side effects, so make(~name=?name(s), ~count=count(s), ()) with two @get externals still reads count first.
8a914c9f8 · mununki · Nov 04, 2026, 20:50 UTC

Read @val globals and synchronous dispatch arguments of externals in place

Why

The review of #9217 asked for two changes. A @val global passed as an event-style argument was bound to a temporary because it may run a getter, while a @module value, which may be a getter as well, was read in place. Reading both in place is simpler and keeps the output globals had before #9217. And an @int argument, or a @string argument with @as, was bound whenever it had side effects, along with the arguments to its left, though the arrow function that switches on it is called where the argument is. Only an await, a syntax error in that function, has to be evaluated before it.

modifiedJS @val global as an event-style argument

type t@val external ev: [#click(int => unit) | #keydown(int => unit)] = "ev"@send external on: (t, @string [#click(int => unit) | #keydown(int => unit)]) => unit = "on" let listen = t => on(t, ev)
js
Before
After
function listen(t) {function listen(t) { let tmp = ev; t.on(tmp.NAME, tmp.VAL); t.on(ev.NAME, ev.VAL); }}

modifiedJS @int argument with side effects between others

type t@send external set: (t, string, @int [#low | #high], string) => unit = "set" let update = (t, g, f, h) => set(t, g(), f(), h())
js
Before
After
function update(t, g, f, h) {function update(t, g, f, h) { let tmp = g(); let tmp$1 = f(); t.set(tmp, (() => { switch (tmp$1) { t.set(g(), (() => { switch (f()) { case "low" : case "low" : return 0; return 0; case "high" : case "high" : return 1; return 1; } } })(), h()); })(), h());}}

modifiedJS temporaries for @int and @as arguments

representation
Before Lam_compile_external_call.hold_arg
After Lam_compile_external_call.hold_arg
f() ↦ temporarypick(async () => await p) ↦ temporary%raw(`"low"`) ↦ temporaryf() ↦ in placepick(async () => await p) ↦ in place%raw(`"low"`) ↦ in placeawait p ↦ temporaryawait p ↦ temporary%raw(`await p`) ↦ temporary%raw(`await p`) ↦ temporary~l=?f(), presence not known ↦ temporary~l=?f(), presence not known ↦ in placex, x.contents, b ? x : y ↦ in placex, x.contents, b ? x : y ↦ in place

Tests: polyvar_external_arg_test.res now expects a @val global passed as an event-style argument to be read for the tag and again for the payload. It adds tests for the order of the arguments around synchronous and awaited @string and @int arguments; an optional @int argument and an optional @obj field whose presence is not known, including None; and an async function and raw code in an @int or @as argument. Compiled with the previous commit, only the getter test fails.

Review

  • What is still bound? An @int or @as argument with an await outside the functions it contains, as setLevel(t, await p), and the arguments to its left, so that they still run first. Raw JS code counts as awaiting unless it is a function or a literal, since the compiler cannot see what it does; that is the output #9217 gave it. An event-style argument with side effects is still bound, since it is read twice, and so is a JS property read: a @get getter, and a @val or @module value with @scope, which compiles to the same property read and cannot be told from it.
  • Why is an optional argument whose presence is not known read in place too? #9283, which this branch builds on, made the temporary unnecessary. lvlOpt(t, ~l=?f(), ()) compiles to Primitive_option.valFromOption(f()), which returns undefined for None since then instead of throwing a TypeError, and hold_obj_args binds such a field of an @obj external itself when evaluating it after the literal could change a value or the order of effects.
  • Why is there no changelog fragment? #9217 was released in 13.0.0-alpha.7 with the entry "Fix an @string or @int argument of an external being evaluated twice, as in on(t, mk()), or breaking await", which still holds: an argument with side effects is still evaluated once, and an awaited one is still bound. This commit brings back the output that compilers before #9217 gave a @val global, and otherwise only drops temporaries.
c4e152819 · mununki · Nov 09, 2026, 15:32 UTC

Pass @int and @as arguments of externals to the function that switches on them

Why

The arrow function that switches on an @int argument, or a @string argument with @as, took no parameter and read the argument inside, so it captured the variables the argument reads. V8 keeps a captured variable alive for as long as any closure created in the same call is, so in the review of #9231 twenty callbacks kept 160 MB alive. 11.1 and 12.3 do this too, with pure arguments as well. Reading the argument inside the function also made an await in it a syntax error, which is why #9217 bound awaited arguments to temporaries. Calling the function with the argument evaluates it in the caller, where an await is legal, and captures nothing.

modifiedJS @int argument

type t@send external setLevel: (t, @int [#low | @as(10) #high]) => unit = "setLevel" let setup = (t, classify, big) => {  setLevel(t, classify(big))  () => t}let later = async (t, p) => setLevel(t, await p)
js
Before
After
function setup(t, classify, big) {function setup(t, classify, big) { t.setLevel((() => { switch (classify(big)) { t.setLevel((x => { switch (x) { case "low" : case "low" : return 0; return 0; case "high" : case "high" : return 10; return 10; } } })()); })(classify(big))); return () => t; return () => t;}}async function later(t, p) {async function later(t, p) { let tmp = await p; t.setLevel((() => { switch (tmp) { t.setLevel((x => { switch (x) { … … } } })()); })(await p));}}

modifiedJS where an @int or @as argument is evaluated

representation
Before Lam_compile_external_call.hold_arg
After Lam_compile_external_call.hold_arg
setLevel(t, f()) ↦ read inside the functionsetLevel(t, await p) ↦ temporarysetLevel(t, %raw(`await p`)) ↦ temporarysetLevelOpt(t, ~level=?f(), ()) ↦ read inside the functionsetLevel(t, f()) ↦ passed to the functionsetLevel(t, await p) ↦ passed to the functionsetLevel(t, %raw(`await p`)) ↦ passed to the functionsetLevelOpt(t, ~level=?f(), ()) ↦ passed to the function

Tests: polyvar_external_arg_test.res checks with a WeakRef and gc() that a closure returned next to an @int argument does not keep the record the argument reads alive. Compiled with #9217 or the previous commit, that test fails. Its output, the outputs of the tests of #9283 and ImportJsValue.res.js in the gentype tests show the function called with the argument.

Review

  • How does the test call gc()? v8.setFlagsFromString with --expose-gc gives new contexts a gc function, which vm.runInNewContext("gc") returns, as Jest's leak detection does. A WeakRef keeps its target until the end of the current task, so the test waits for a timer before it collects. It failed 20 runs out of 20 with the previous commit and passed 20 out of 20 with this one.
  • Why does raw code that ends in a line comment, such as "light" // default mode, still break the module as an @int or @as argument? The line comment hides the parenthesis that closes the call, as it does for any argument: a string argument given that raw code compiles to t.setName("light" // default mode) with every compiler. #9217 put such an argument in a temporary on a line of its own, which hid the bug. The previous commit, like compilers before #9217, left it inside the switch, where it hid the rest of the line, as the review of #9231 found. Ending raw code that ends in a line comment with a line break belongs in the printer, as a fix of its own.
  • What goes? Js_of_lam_variant.awaits and eval_in_place, the temporaries for awaited arguments, and Js_exp_make.of_block, which had no other use. hold_arg now binds only an event-style argument, which cannot be optional, so it loses its arg_label parameter.
f9f501b97 · mununki · Nov 09, 2026, 15:38 UTC

Evaluate the effects of unit and @ignore arguments of externals in their place

Why

An external leaves a unit or @ignore argument out of the JS call, and translate_ffi and assemble_obj_args evaluated the effects of one before the whole call or object. So setX(t, f(), g()) called g() before f(), with 11.1 and 12.3 and with every kind of external. The temporaries of #9217 hid it only after a polymorphic variant argument with side effects, and the previous commits brought it back there (regressions 2 and 5 in the review of #9231). hold_arg now makes the effects a statement of the argument, so sequence_arguments evaluates the arguments to its left first, binding only those that the statement could change, as it does for any argument with statements. Nothing is hoisted any more, so the effect lists of ocaml_to_js_eff, assemble_args_* and assemble_obj_args go, with add_eff and Js_exp_make.fuse_to_seq, which had no other use, and ocaml_to_js_eff becomes ocaml_to_js.

modifiedJS unit argument

type t@send external setX: (t, int, unit) => unit = "setX" let update = (t, f, g) => setX(t, f(), g())
js
Before
After
function update(t, f, g) {function update(t, f, g) { let tmp = f(); g(); g(); t.setX(f()); t.setX(tmp);}}

modifiedJS unit argument in a while condition

type t@send external ready: (t, unit) => bool = "ready" let wait = (t, g) =>  while ready(t, g()) {    ()  }
js
Before
After
function wait(t, g) {function wait(t, g) { while (g(), t.ready()) { while (true) { g(); if (!t.ready()) { break; } }; };}}

modifiedJS optional @ignore argument

type t@send external push: (t, int, ~extra: @ignore int=?, unit) => unit = "push" let add = (t, g) => push(t, 1, ~extra=?g(), ())
js
Before
After
function add(t, g) {function add(t, g) { Primitive_option.valFromOption(g()); g(); t.push(1); t.push(1);}}

modifiedJS external whose arguments are all @ignore

@val external count: (@ignore int, @ignore int) => int = "count"@module external path: @ignore int => {..} = "node:path" let counted = (f, g) => count(f(), g())let loaded = g => path(g())
js
Before
After
function counted(f, g) {function counted(f, g) { return count(f(), g()); f(); g(); return count();}}function loaded(g) {function loaded(g) { g(); return Nodepath; return Nodepath;}}

modifiedJS where the effects of a unit or @ignore argument run

representation
Before Lam_compile_external_call.ocaml_to_js_eff
After Lam_compile_external_call.hold_arg
setX(t, f(), g()), the unit argument g() ↦ before the whole callmakeThen(~a=f(), g()), the unit argument g() ↦ before the objectpush(t, 1, ~extra=?g(), ()) ↦ valFromOption(g()), before the callcount(f(), g()) ↦ passed to countpath(g()) ↦ droppedsetX(t, f(), g()), the unit argument g() ↦ statement in its placemakeThen(~a=f(), g()), the unit argument g() ↦ statement in its placepush(t, 1, ~extra=?g(), ()) ↦ g(), statement in its placecount(f(), g()) ↦ statements in their place, not passedpath(g()) ↦ statement in its place

Tests: bs_ignore_effect_test.res checks the order of unit and @ignore arguments after others, with @send and @obj externals, a unit argument that changes an earlier one, an absent optional @ignore argument, and externals whose arguments are all @ignore. polyvar_external_arg_test.res checks the order after @string and @int arguments, labelled, optional and in an @obj literal, whose optional field known to be present stays in the literal, and a unit argument that changes an event-style @val global before it is read. Compiled with #9217 or the previous commit, these tests fail. bs_ignore_effect_test.res also checks that a @send external whose object is marked @ignore still calls the method on that object.

Review

  • Why does some output get bigger? A unit argument with side effects now has a statement. A while condition with statements becomes a loop that breaks, and a call with one, as an element of an array or an argument of another call, evaluates the elements or arguments to its left first, binding those with side effects: [ready(t, g()), ready(t, h())] compiles to g(); let tmp = t.ready(); h(); return [tmp, t.ready()]; instead of [(g(), t.ready()), (h(), t.ready())]. Hoisting the effects as before is right only when every argument to their left can be read after them, which would keep the effect lists that this commit removes; the old order was wrong whenever that did not hold. Outside the new tests, three such sequences in checked-in outputs become statements, two in bs_ignore_effect_test.mjs and one in optional_external_arg_once_test.mjs of #9283.
  • Why does hold_args take the declaration of the external? translate_ffi calls the method of a @send external on its first argument whatever that argument's spec says, and the frontend accepts @ignore there, as in a @send external push of type (@ignore array<int>, int) => int. So hold_args leaves that argument as it is, where it would replace an @ignore one with undefined, and push(arr, 1) still compiles to arr.push(1).
  • Why are the arguments of an external whose arguments are all @ignore no longer passed? Every other external leaves @ignore arguments out. translate_ffi took such an external for a JS value and called it with the arguments it got, which hold_arg now replaces with undefined. A JS value whose type is a function type it does not spell out, such as @val external v: u = "v" with type u = (int, int) => int, has no argument specs and still gets its arguments, as ExternalArity.res checks. A @module external of the module itself still is the module, but it no longer drops the effects of its arguments.
  • What about an optional @ignore argument? Only its payload is evaluated, for its effects, so it no longer goes through Primitive_option.valFromOption, whose result was dropped. One without side effects, such as a variable, a field or a @get property, is not read at all, where it was tested for None and unwrapped, or given to valFromOption.
28b9c605b · mununki · Nov 09, 2026, 15:40 UTC

Bind dotted @val event arguments of externals

Why

The first commit reads a @val global in place for the tag and for the payload of an event-style argument, like a @module value. A @val with a dotted name such as "window.ev" compiles to a variable printed as window.ev, so it was read in place too, though it is the same property read as @val @scope("window") external ev: … = "ev", which keeps its temporary (a lower-severity regression in the review of #9231). Js_analyzer.is_okay_to_duplicate ~property_reads:false, which #9283 added for optional arguments, already tells such a name from a variable, so duplicable now defers to it, as the review suggested, and adds array elements and conditionals, and fields of them.

modifiedJS @val with a dotted name as an event-style argument

type t@val external ev: [#click(int => unit) | #keydown(int => unit)] = "window.ev"@send external on: (t, @string [#click(int => unit) | #keydown(int => unit)]) => unit = "on" let listen = t => on(t, ev)
js
Before
After
function listen(t) {function listen(t) { t.on(window.ev.NAME, window.ev.VAL); let tmp = window.ev; t.on(tmp.NAME, tmp.VAL);}}

modifiedJS event-style arguments read twice

representation
Before Js_of_lam_variant.eval_as_event_in_place
After Js_of_lam_variant.eval_as_event_in_place
x, r.ev, ev, M.ev ↦ read twicex, r.ev, ev, M.ev ↦ read twicewindow.ev, as @val "window.ev" ↦ read twicewindow.ev, as @val "window.ev" ↦ temporarywindow.ev, as @val @scope("window") "ev" ↦ temporarywindow.ev, as @val @scope("window") "ev" ↦ temporarys.ev, as @get ↦ temporarys.ev, as @get ↦ temporaryarr[i], c ? x : y, rs[i].ev ↦ read twicearr[i], c ? x : y, rs[i].ev ↦ read twicef() ↦ temporaryf() ↦ temporary
noteev is a @val global and M.ev a @module value
noteev is a @val global and M.ev a @module value

Tests: polyvar_external_arg_test.res checks that a @val with a dotted name is read once, like the same name with @scope. Compiled with the previous commit, it fails.

Review

  • Why does duplicable add array elements and conditionals, and fields of them, to is_okay_to_duplicate? That predicate leaves them out, and the other callers that pass ~property_reads:false read such an argument once instead: an optional argument of a call goes to Primitive_option.valFromOption, and one of an @obj external to a temporary after the literal. The alternative for an event-style argument is a temporary before the call, a statement that makes sequence_arguments bind the arguments to its left that have side effects, so these reads without side effects stay in place, as #9217 left them. A field of a variable is already in is_okay_to_duplicate; duplicable takes fields itself only to reach the array element or conditional under them, as in rs[i].ev. No other output changes.
  • Why is a @val global without a dot still read twice, though a getter may then give the tag of one event and the payload of another? The review of #9217 asked to treat a @val global like a @module value, which Babel or TypeScript may compile to a getter as well, and to read both in place, as compilers before #9217 did. A later argument that changes the global is no longer a problem since the previous commit: its effects are statements, before which the global is bound.
  • Why no changelog fragment? A dotted @val was bound in 13.0.0-alpha.7 as well, so this only takes back what the first commit changed for it.