Skip to content

Evaluate a polymorphic variant argument of an external once, in place - #9217

Open
mununki wants to merge 1 commit into
masterfrom
evaluate-polyvar-arguments-once
Open

mununki wants to merge 1 commit into
masterfrom
evaluate-polyvar-arguments-once

Conversation

@mununki

@mununki mununki commented Nov 4, 2026

Copy link
Copy Markdown
Member

Fixes: #9215

A polymorphic variant argument of an external, with @int or with a @string whose constructors have a payload or an @as, was evaluated twice or inside a function the compiler generates, unless it was a literal or a variable. An event-style argument is passed as its tag and its payload, so on(t, next()) compiled to t.on(next().NAME, next().VAL) and called next() twice, and on(t, await p) awaited p twice. An @int argument, or a @string one with @as, went into an arrow function that switches on it: setLevel(t, await p) compiled to t.setLevel((() => { switch (await p) { ... } })()), which fails to load with a SyntaxError, and so did an awaited @int field of an @obj external. A literal whose payload has side effects, on(t, #click(wrap(handler))), copied the whole object literal into both arguments, and a @get getter passed as an event-style argument ran twice.

The translations in Js_of_lam_variant (eval, eval_as_int and eval_as_event) return an expression, so they cannot bind the argument first; the FIXME about it dates from #2633. Lam_compile.compile_arguments now takes a hold step that runs after the arguments are compiled and before sequence_arguments orders them. For Pjs_call and Pjs_object_create, Lam_compile_external_call.hold_arg binds a polymorphic variant argument to a temporary after its statements, unless the translation can read it in place, so it runs once and in its place among the arguments. A literal event now passes its payload as it is. The temporaries are statements, and compile_while put a condition with statements into a closure called on each iteration, where await, break and continue are syntax errors. Such a condition now compiles to while (true) { <statements>; if (!cond) break; <body> }. That part is the same change as in #9177, with the same new output in complex_while_loop.mjs and test_while_side_effect.mjs, so whichever pull request lands second drops it.

  • Literals, constants and variables compile as before. So does any argument without side effects for an @int or @string dispatch, which reads it once, and a field, array element or conditional of variables passed as an event-style argument. A @val global or a @get property read passed as an event-style argument is now bound, because it may run a getter.
  • When an argument gets a temporary, the arguments to its left run first, in temporaries of their own unless they are constants or variables, as sequence_arguments already does for arguments with statements. Such a call in an operand of &&, || or ?: compiles to statements under an if.
  • @unwrap, @ignore and other arguments of externals compile as before, and the runtime and Belt compile to the same JavaScript. The checked-in output changes only in gpr_2633_test.mjs, which pinned foo.on(h(event).NAME, h(event).VAL), and in the two while tests above.

Tests: polyvar_external_arg_test.res has mocha tests that count the side effects of event-style, @as, @string and @int arguments, check the order of the arguments around them, getters, refs, and await in arguments and in @obj fields, and cover a while condition with a temporary, together with break, continue and await. Compiled with master, the file fails to load, and four of its six synchronous tests fail.

Checklist

  • Added a changelog fragment (see changes/README.md), or no release notes are needed and a maintainer should apply changelog:skip.
  • Added tests for the fix or feature.
  • Gave every commit a description below its subject, saying why it is made (see "Commit messages" in CONTRIBUTING.md).
  • Signed off every commit for the DCO (git commit -s).

Why:
    The translation of a polymorphic variant argument of an external read
    an argument other than a literal twice, as `arg.NAME` and `arg.VAL` of
    an event-style `@string`, or inside the arrow function that switches
    on it for `@int` and `@as`. So `on(t, next())` called `next()` twice,
    and an `await` in the argument was a syntax error in the arrow
    function. The functions in `Js_of_lam_variant` return an expression
    and cannot bind the argument, so `Lam_compile` now binds it to a
    temporary among the statements of the call's arguments, unless reading
    it twice or in the function is the same as reading it once.

JS modified (polymorphic variant argument):
Example:
    type t
    @send external on: (t, @string [#click(int => unit) | #keydown(string => unit)]) => unit = "on"
    @send external setLevel: (t, @int [#low | #high]) => unit = "setLevel"

    let f = (t, next) => on(t, next())
    let g = async (t, p) => setLevel(t, await p)
Before (js): `g` is a SyntaxError when the module loads
    function f(t, next) {
      t.on(next().NAME, next().VAL);
    }
    async function g(t, p) {
      t.setLevel((() => {
        switch (await p) {
          …
        }
      })());
    }
After (js):
    function f(t, next) {
      let tmp = next();
      t.on(tmp.NAME, tmp.VAL);
    }
    async function g(t, p) {
      let tmp = await p;
      t.setLevel((() => {
        switch (tmp) {
          …
        }
      })());
    }

JS modified (literal with a payload):
Example:
    type t
    @send external on: (t, @string [#click(int => unit) | #keydown(string => unit)]) => unit = "on"
    @Val external wrap: (int => unit) => int => unit = "wrap"

    let l = (t, handler) => on(t, #click(wrap(handler)))
Before (js):
    function l(t, handler) {
      t.on(({
        NAME: "click",
        VAL: wrap(handler)
      }).NAME, ({
        NAME: "click",
        VAL: wrap(handler)
      }).VAL);
    }
After (js):
    function l(t, handler) {
      t.on("click", wrap(handler));
    }

JS modified (while condition with statements):
Example:
    type t
    @send external has: (t, @string [#click(int => unit) | #keydown(string => unit)]) => bool = "has"

    let w = async (t, p) => {
      let n = ref(0)
      while has(t, await p) {
        n := n.contents + 1
      }
      n.contents
    }
Before (js):
    async function w(t, p) {
      let n = 0;
      while (t.has((await p).NAME, (await p).VAL)) {
        n = n + 1 | 0;
      };
      return n;
    }
After (js):
    async function w(t, p) {
      let n = 0;
      while (true) {
        let tmp = await p;
        if (!t.has(tmp.NAME, tmp.VAL)) {
          break;
        }
        n = n + 1 | 0;
      };
      return n;
    }

Review: Which arguments keep their output? Literals, constants and
    variables, and for an `@int` or `@string` dispatch, which reads the
    argument once, any argument without side effects. An event-style
    argument is read twice, so fields, array elements and conditionals of
    variables stay in place, but a `@val` global or a `@get` property read
    is bound, since it may run a getter. `gpr_2633_test.mjs` changes
    because it pinned `foo.on(h(event).NAME, h(event).VAL)`.

Review: Why does `compile_while` change? The temporaries are statements,
    and it put a condition with statements into a closure called on each
    iteration, where an `await`, `break` or `continue` is a syntax error,
    so `while has(t, await p)` would stop loading. Such a condition now
    compiles to `while (true) { <statements>; if (!cond) break; <body> }`.
    The change is the same as in #9177, with the same new output in
    `complex_while_loop.mjs` and `test_while_side_effect.mjs`.

Signed-off-by: mununki <woonki.moon@gmail.com>
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Changelog

🐛 Bug fix

  • Evaluate a polymorphic variant argument of an external once, in its place among the arguments. An event-style @string argument such as on(t, mk()) ran mk() twice, and an await in an @int argument, or in a @string argument with @as, was put in a generated function, which is a syntax error.

Semantic Diff

1 commit · Full page

Evaluate a polymorphic variant argument of an external once, in place

8b9c6cb · mununki · Nov 04, 2026, 05:19 UTC

Why

The translation of a polymorphic variant argument of an external read an argument other than a literal twice, as arg.NAME and arg.VAL of an event-style @string, or inside the arrow function that switches on it for @int and @as. So on(t, next()) called next() twice, and an await in the argument was a syntax error in the arrow function. The functions in Js_of_lam_variant return an expression and cannot bind the argument, so Lam_compile now binds it to a temporary among the statements of the call's arguments, unless reading it twice or in the function is the same as reading it once.

JS modified (polymorphic variant argument)

type t
@send external on: (t, @string [#click(int => unit) | #keydown(string => unit)]) => unit = "on"
@send external setLevel: (t, @int [#low | #high]) => unit = "setLevel"

let f = (t, next) => on(t, next())
let g = async (t, p) => setLevel(t, await p)

js · Before: g is a SyntaxError when the module loads

 function f(t, next) {
-  t.on(next().NAME, next().VAL);
+  let tmp = next();
+  t.on(tmp.NAME, tmp.VAL);
 }
 async function g(t, p) {
+  let tmp = await p;
   t.setLevel((() => {
-    switch (await p) {
+    switch (tmp) {
       …
     }
   })());
 }

JS modified (literal with a payload)

type t
@send external on: (t, @string [#click(int => unit) | #keydown(string => unit)]) => unit = "on"
@val external wrap: (int => unit) => int => unit = "wrap"

let l = (t, handler) => on(t, #click(wrap(handler)))

js

 function l(t, handler) {
-  t.on(({
-    NAME: "click",
-    VAL: wrap(handler)
-  }).NAME, ({
-    NAME: "click",
-    VAL: wrap(handler)
-  }).VAL);
+  t.on("click", wrap(handler));
 }

JS modified (while condition with statements)

type t
@send external has: (t, @string [#click(int => unit) | #keydown(string => unit)]) => bool = "has"

let w = async (t, p) => {
  let n = ref(0)
  while has(t, await p) {
    n := n.contents + 1
  }
  n.contents
}

js

 async function w(t, p) {
   let n = 0;
-  while (t.has((await p).NAME, (await p).VAL)) {
+  while (true) {
+    let tmp = await p;
+    if (!t.has(tmp.NAME, tmp.VAL)) {
+      break;
+    }
     n = n + 1 | 0;
   };
   return n;
 }

Review

  • Which arguments keep their output? Literals, constants and variables, and for an @int or @string dispatch, which reads the argument once, any argument without side effects. An event-style argument is read twice, so fields, array elements and conditionals of variables stay in place, but a @val global or a @get property read is bound, since it may run a getter. gpr_2633_test.mjs changes because it pinned foo.on(h(event).NAME, h(event).VAL).
  • Why does compile_while change? The temporaries are statements, and it put a condition with statements into a closure called on each iteration, where an await, break or continue is a syntax error, so while has(t, await p) would stop loading. Such a condition now compiles to while (true) { <statements>; if (!cond) break; <body> }. The change is the same as in Evaluate the arguments of an inlined call left to right #9177, with the same new output in complex_while_loop.mjs and test_while_side_effect.mjs.

@codecov

codecov Bot commented Nov 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.10345% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.78%. Comparing base (227b697) to head (8b9c6cb).

Files with missing lines Patch % Lines
compiler/core/js_of_lam_variant.ml 76.92% 3 Missing ⚠️
compiler/core/lam_compile_external_call.ml 94.73% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9217      +/-   ##
==========================================
+ Coverage   81.75%   81.78%   +0.03%     
==========================================
  Files         457      457              
  Lines       65332    65380      +48     
==========================================
+ Hits        53413    53474      +61     
+ Misses      11919    11906      -13     
Files with missing lines Coverage Δ
compiler/core/lam_compile.ml 88.94% <100.00%> (+0.60%) ⬆️
compiler/core/lam_compile_external_obj.ml 95.45% <100.00%> (+0.90%) ⬆️
compiler/core/lam_compile_external_call.ml 92.98% <94.73%> (+0.21%) ⬆️
compiler/core/js_of_lam_variant.ml 85.41% <76.92%> (+27.52%) ⬆️

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new

pkg-pr-new Bot commented Nov 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@9217

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@9217

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@9217

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@9217

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@9217

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@9217

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@9217

commit: 8b9c6cb

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

@mununki

mununki commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-04T13:26:41.283939Z 8b9c6cb Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 8b9c6cbb81

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

This branch has not been deployed

No deployments
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.

A polymorphic variant argument of an external is evaluated twice, or inside a generated function

2 participants