Skip to content

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

Merged
mununki merged 1 commit into
masterfrom
pass-undefined-for-none-optional-arguments
Nov 10, 2026
Merged

mununki merged 1 commit into
masterfrom
pass-undefined-for-none-optional-arguments

Conversation

@mununki

@mununki mununki commented Nov 9, 2026

Copy link
Copy Markdown
Member

Fixes: #9226

An optional argument of an external passed as ~x=?e threw a TypeError when e was None and was not a variable or a field of one, as in ~name=?names->Dict.get(id), ~x=?f(), ~x=?a[i] or a conditional. It compiles to Primitive_option.valFromOption(e), which read a property of undefined. This regressed in 13.0.0-alpha.5 (#8380), and it also broke optional JSX keys (with -bs-jsx-preserve too), inlined functions that forward ~x?, and optional @int and @as arguments.

Primitive_option.valFromOption now returns undefined for undefined (x == null instead of x === null), as its doc comment says and #8380 assumed. Every path that emits the unchecked call goes through it, so the generated code stays the same, and code that 13.0.0-alpha.5 or later already compiled works with the updated runtime.

  • The other callers of valFromOption pass a value known to be Some; for them x == null replaces x === null, still one comparison.
  • The only checked-in output that changes is Primitive_option.mjs/.cjs. The rest of the runtime, the stdlib, Belt and the other tests compile to the same JavaScript.

Tests: optional_external_arg_none_test.res passes None from a call, a lookup and a conditional to @val, @send and @new externals, an inlined wrapper, @int and @string arguments, and a JSX key. master compiles it to the same JavaScript, and its five tests fail with master's runtime.

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:
    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.

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

Output modified (optional argument that is None):
Example:
    %%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"))
Before (js):
    function greetUser(names, id) {
      return greet(Primitive_option.valFromOption(names[id]));
    }
After (js):
    function greetUser(names, id) {
      return greet(Primitive_option.valFromOption(names[id]));
    }
Before (text):
    TypeError: Cannot read properties of undefined (reading 'BS_PRIVATE_NESTED_SOME_NONE')
After (text):
    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.
Review: 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.
Review: 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.

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

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

Changelog

🐛 Bug fix

  • Fix a TypeError when an optional external argument such as ~x=?dict->Dict.get(k), or an optional JSX key, is None.

Semantic Diff

1 commit · Full page

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

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

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.

JS modified (Primitive_option.valFromOption)

js

 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;
   }
   …
 }

Output modified (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

-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 Remove Primitive_option.toUndefined; use valFromOption for optional ffi args #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.

@codecov

codecov Bot commented Nov 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.92%. Comparing base (e941d27) to head (c907500).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #9288   +/-   ##
=======================================
  Coverage   81.92%   81.92%           
=======================================
  Files         456      456           
  Lines       65469    65464    -5     
=======================================
- Hits        53635    53631    -4     
+ Misses      11834    11833    -1     
Files with missing lines Coverage Δ
compiler/core/js_of_lam_option.ml 96.66% <100.00%> (ø)

... and 8 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.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

✅ Compiler benchmarks

Allocated words

In total: 683,553,916 → 683,552,892 (±0.00%).

Benchmark Base Head Change
packages/@rescript/runtime/Primitive_option.res (inputs changed) 315,661 314,619 -0.33%

(inputs changed): this pull request changes runtime or Belt sources, so these benchmarks compile different code than on the base, and their changes don't count as regressions.

Instructions

In total: 9,664,671,992 → 9,664,671,992 (±0.00%).

None of the 33 benchmarks changed by 0.1% or more.

Output size

In total: 25,569,598 → 25,569,647 (±0.00%).

None of the 13 benchmarks changed by 0.1% or more.

Pull request c907500 compared with base b82d944 in this run.

@pkg-pr-new

pkg-pr-new Bot commented Nov 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: c907500

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

@mununki

mununki commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

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

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T18:16:07.832265Z c907500 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. More of your lovely PRs please.

Reviewed commit: c907500383

ℹ️ 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".

@mununki
mununki requested review from cknitt and cristianoc November 9, 2026 18:16

@cristianoc cristianoc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed c907500. The runtime fix preserves undefined and null and still unwraps nested options correctly. Focused Node checks passed, and the main CI, Coverage and Changelog workflows passed. I did not rerun the full compiler suite locally.

The remaining failure is the separate Semantic Diff pull_request_target run: GitHub reports startup_failure and it has no jobs, so there is no failing test from that run to diagnose. The pull_request Semantic Diff run succeeded. The startup failure still needs attention, but I found no code regression blocking approval.

@cknitt cknitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for the fix!


function valFromOption(x) {
if (x === null || x.BS_PRIVATE_NESTED_SOME_NONE === undefined) {
if ((x == null) || x.BS_PRIVATE_NESTED_SOME_NONE === undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting that this differs in terms of parentheses, maybe something for a followup.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

testAny compiles to Is_null_or_undefined, which Js_dump put in parentheses whenever it was not at the top level, instead of by precedence as for x === null. Its operand was also printed at a fixed low level, so it could lose parentheses it needs: Nullable.isNullable(c ? a : b) compiled to c ? a : b == null, and so did an if or switch expression there. That goes back to at least 11.1. Filed as #9290 and fixed in #9291 by printing it with the precedence of ===.

@mununki
mununki merged commit eb467b0 into master Nov 10, 2026
26 checks passed
@mununki
mununki deleted the pass-undefined-for-none-optional-arguments branch November 10, 2026 14:34
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.

An external call throws a TypeError when ~x=? is given a call or lookup that returns None

3 participants