From aba8589ce07a56da58b124991d9065e417d05088 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Tue, 13 Oct 2026 21:43:53 +0200 Subject: [PATCH 1/4] Add --js-wasm-jspi and //> using wasmJspi to enable JSPI on Wasm Since Scala.js 1.22 the linker links js.async/js.await on WebAssembly only when WasmFeatures.useJSPI is set; otherwise linking fails with "Uses an async block without JSPI support in WebAssembly". The option is passed to the Scala.js CLI as --useJSPI, which needs a Scala.js CLI release containing VirtusLab/scala-js-cli#209. Co-Authored-By: Claude Opus 5.5 --- .../scala/build/tests/SourcesTests.scala | 34 +++++++++++++++++++ .../cli/commands/shared/ScalaJsOptions.scala | 5 +++ .../cli/commands/shared/SharedOptions.scala | 1 + .../build/preprocessing/directives/Wasm.scala | 14 ++++++-- .../RunScalaJsTestDefinitions.scala | 30 ++++++++++++++++ .../build/internal/ScalaJsLinkerConfig.scala | 7 ++-- .../scala/build/options/ScalaJsOptions.scala | 8 ++++- .../build/options/ScalaJsOptionsTests.scala | 20 +++++++++++ website/docs/guides/advanced/scala-wasm.md | 16 ++++++++- website/docs/reference/cli-options.md | 4 +++ website/docs/reference/directives.md | 6 ++++ 11 files changed, 139 insertions(+), 6 deletions(-) diff --git a/modules/build/src/test/scala/scala/build/tests/SourcesTests.scala b/modules/build/src/test/scala/scala/build/tests/SourcesTests.scala index 56c6724d6e..b917d7969d 100644 --- a/modules/build/src/test/scala/scala/build/tests/SourcesTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/SourcesTests.scala @@ -796,6 +796,40 @@ class SourcesTests extends TestUtil.ScalaCliBuildSuite { } } + test("wasmJspi using directive enables JSPI for Wasm output") { + val testInputs = TestInputs( + os.rel / "something.sc" -> + """//> using wasm + |//> using wasmJspi + |//> using jsModuleKind es + |""".stripMargin + ) + testInputs.withInputs { (root, inputs) => + val (crossSources, _) = + CrossSources.forInputs( + inputs, + preprocessors, + TestLogger(), + SuppressWarningOptions() + ).orThrow + + val scopedSources = crossSources.scopedSources(BuildOptions()).orThrow + val sources = + scopedSources.sources( + Scope.Main, + crossSources.sharedOptions(BuildOptions()), + root, + TestLogger() + ) + .orThrow + + val jsOptions = sources.buildOptions.scalaJsOptions + val jsConfig = jsOptions.linkerConfig(TestLogger()).orThrow + expect(jsOptions.jsEmitWasm, jsOptions.jsWasmJspi) + expect(jsConfig.linkerCliArgs.contains("--useJSPI")) + } + } + test("js options in using directives failure - multiple values") { val testInputs = TestInputs( os.rel / "something.sc" -> diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalaJsOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalaJsOptions.scala index 3262ad6f6f..287dcd9a8c 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalaJsOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalaJsOptions.scala @@ -64,6 +64,11 @@ final case class ScalaJsOptions( @HelpMessage("Enable Wasm output (Scala.js Wasm backend). Uses Node.js by default. To show more options for Wasm pass `--help-wasm`") jsEmitWasm: Option[Boolean] = None, + @Group(HelpGroup.Wasm.toString) + @Tag(tags.experimental) + @HelpMessage("Use JavaScript Promise Integration (JSPI) in Wasm output, required by js.async/js.await (Scala.js 1.22+)") + jsWasmJspi: Option[Boolean] = None, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.experimental) @HelpMessage("JS runtime to run the output on: node (default), deno, bun") diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala index d9a0e23bf5..bf57ba6452 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala @@ -308,6 +308,7 @@ final case class SharedOptions( remapEsModuleImportMap = jsEsModuleImportMap.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)), jsEmitWasm = jsEmitWasm.getOrElse(false), + jsWasmJspi = jsWasmJspi.getOrElse(false), jsRuntime = parsedRuntime ) } diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala index 2720952999..8291486be6 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala @@ -8,16 +8,21 @@ import scala.cli.commands.SpecificationLevel @DirectiveGroupName("Wasm options") @DirectiveExamples("//> using wasm") +@DirectiveExamples("//> using wasmJspi") @DirectiveExamples("//> using jsRuntime node") @DirectiveExamples("//> using jsRuntime deno") @DirectiveExamples("//> using jsRuntime bun") @DirectiveUsage( - "//> using wasm|jsRuntime _value_", + "//> using wasm|wasmJspi|jsRuntime _value_", """ |`//> using wasm` _true|false_ | |`//> using wasm` | + |`//> using wasmJspi` _true|false_ + | + |`//> using wasmJspi` + | |`//> using jsRuntime` _node|deno|bun_ |""".stripMargin ) @@ -25,6 +30,7 @@ import scala.cli.commands.SpecificationLevel @DirectiveLevel(SpecificationLevel.EXPERIMENTAL) final case class Wasm( wasm: Option[Boolean] = None, + wasmJspi: Option[Boolean] = None, jsRuntime: Option[String] = None ) extends HasBuildOptions { def buildOptions: Either[BuildException, BuildOptions] = { @@ -47,7 +53,11 @@ final case class Wasm( ScalaOptions() BuildOptions( scalaOptions = scalaOptions, - scalaJsOptions = ScalaJsOptions(jsEmitWasm = wasmEnabled, jsRuntime = runtime) + scalaJsOptions = ScalaJsOptions( + jsEmitWasm = wasmEnabled, + jsWasmJspi = wasmJspi.getOrElse(false), + jsRuntime = runtime + ) ) } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala index 66dac63aef..9c88229c0d 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala @@ -428,6 +428,36 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions => } } + test("Run Wasm with js.async/js.await via --js-wasm-jspi") { + val inputs = TestInputs( + os.rel / "Async.scala" -> + """import scala.scalajs.js + | + |object Async { + | def main(args: Array[String]): Unit = + | js.async { + | val x = js.await(js.Promise.resolve[Int](21)) + | println(s"JSPI ${x * 2}") + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Async.scala", + "--js-emit-wasm", + "--js-module-kind", + "es", + "--js-wasm-jspi", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "JSPI 42") + } + } + test("Wasm without an ES module kind fails fast") { val inputs = TestInputs( os.rel / "Hello.scala" -> diff --git a/modules/options/src/main/scala/scala/build/internal/ScalaJsLinkerConfig.scala b/modules/options/src/main/scala/scala/build/internal/ScalaJsLinkerConfig.scala index 947af0b93c..cf9169c6c8 100644 --- a/modules/options/src/main/scala/scala/build/internal/ScalaJsLinkerConfig.scala +++ b/modules/options/src/main/scala/scala/build/internal/ScalaJsLinkerConfig.scala @@ -12,7 +12,8 @@ final case class ScalaJsLinkerConfig( prettyPrint: Boolean = false, relativizeSourceMapBase: Option[String] = None, remapEsModuleImportMap: Option[os.Path] = None, - emitWasm: Boolean = false + emitWasm: Boolean = false, + wasmUseJspi: Boolean = false ) { def linkerCliArgs: Seq[String] = { val moduleKindArgs = Seq("--moduleKind", moduleKind) @@ -36,6 +37,7 @@ final case class ScalaJsLinkerConfig( Seq("--importmap", remapEsModuleImportMap.getOrElse(os.pwd / "importmap.json").toString) else Nil val jsEmitWasm = if (emitWasm) Seq("--emitWasm") else Nil + val jsWasmJspi = if (emitWasm && wasmUseJspi) Seq("--useJSPI") else Nil val configArgs = Seq[os.Shellable]( moduleKindArgs, @@ -48,7 +50,8 @@ final case class ScalaJsLinkerConfig( jsHeaderArg, prettyPrintArgs, jsEsModuleImportMap, - jsEmitWasm + jsEmitWasm, + jsWasmJspi ) configArgs.flatMap(_.value) diff --git a/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala b/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala index 3edd307aaa..64dda0a9b7 100644 --- a/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala @@ -32,6 +32,7 @@ final case class ScalaJsOptions( esVersionStr: Option[String] = None, noOpt: Option[Boolean] = None, jsEmitWasm: Boolean = false, + jsWasmJspi: Boolean = false, jsRuntime: JSRuntime = JSRuntime.default ) { def fullOpt: Either[UnrecognizedJsOptModeError, Boolean] = @@ -136,6 +137,10 @@ final case class ScalaJsOptions( def linkerConfig(logger: Logger): Either[BuildException, ScalaJsLinkerConfig] = esVersion.map { resolvedEsVersion => + if jsWasmJspi && !jsEmitWasm then + logger.message( + "Warning: --js-wasm-jspi only applies to Wasm output (--js-emit-wasm), ignoring it" + ) val esFeatureDefaults = ScalaJsLinkerConfig.ESFeatures() val esFeatures = ScalaJsLinkerConfig.ESFeatures( allowBigIntsForLongs = @@ -154,7 +159,8 @@ final case class ScalaJsOptions( esFeatures = esFeatures, jsHeader = header, remapEsModuleImportMap = remapEsModuleImportMap, - emitWasm = jsEmitWasm + emitWasm = jsEmitWasm, + wasmUseJspi = jsWasmJspi ) } diff --git a/modules/options/src/test/scala/scala/build/options/ScalaJsOptionsTests.scala b/modules/options/src/test/scala/scala/build/options/ScalaJsOptionsTests.scala index 2d94627114..92f71372d5 100644 --- a/modules/options/src/test/scala/scala/build/options/ScalaJsOptionsTests.scala +++ b/modules/options/src/test/scala/scala/build/options/ScalaJsOptionsTests.scala @@ -83,4 +83,24 @@ class ScalaJsOptionsTests extends munit.FunSuite { test("the es version is passed to the linker") { expect(linkerArgsFor("es2022").containsSlice(Seq("--esVersion", "ES2022"))) } + + private def linkerArgs(options: ScalaJsOptions): Seq[String] = + options.linkerConfig(Logger.nop) match { + case Right(config) => config.linkerCliArgs + case Left(e) => sys.error(s"Expected a linker config, got $e") + } + + test("JSPI is passed to the linker with Wasm output") { + val args = linkerArgs(ScalaJsOptions(jsEmitWasm = true, jsWasmJspi = true)) + expect(args.contains("--emitWasm")) + expect(args.contains("--useJSPI")) + } + + test("JSPI is off by default") { + expect(!linkerArgs(ScalaJsOptions(jsEmitWasm = true)).contains("--useJSPI")) + } + + test("JSPI is not passed to the linker without Wasm output") { + expect(!linkerArgs(ScalaJsOptions(jsWasmJspi = true)).contains("--useJSPI")) + } } diff --git a/website/docs/guides/advanced/scala-wasm.md b/website/docs/guides/advanced/scala-wasm.md index 13d0c5098f..129a1c5b3b 100644 --- a/website/docs/guides/advanced/scala-wasm.md +++ b/website/docs/guides/advanced/scala-wasm.md @@ -70,7 +70,8 @@ enough to require it, pass it yourself via the runtime's own environment variabl The same mechanism gives you any **optional** feature flags — Scala CLI never injects these either: -- `--experimental-wasm-jspi` — required to use `js.async` / `js.await`. +- `--experimental-wasm-jspi` — required to use `js.async` / `js.await` on runtimes that do not + enable JSPI by default (Node.js 25+ does). - `--experimental-wasm-imported-strings` — improves performance. - `--turboshaft-wasm` — improves stability on Node.js 23.x (Node.js 24+ enables it by default and has removed the flag). @@ -79,6 +80,19 @@ The same mechanism gives you any **optional** feature flags — Scala CLI never NODE_OPTIONS=--experimental-wasm-jspi scala-cli run Hello.scala --js-emit-wasm --js-module-kind es ``` +## `js.async` / `js.await` (JSPI) + +On the Wasm backend, `js.async` / `js.await` use JavaScript Promise Integration (JSPI). Since +Scala.js 1.22 the linker only links them when JSPI is enabled explicitly. Pass `--js-wasm-jspi` +(or use the `//> using wasmJspi` directive) together with `--js-emit-wasm`: + +```bash ignore +scala-cli run Async.scala --js-emit-wasm --js-module-kind es --js-wasm-jspi +``` + +Without it, linking fails with `Uses an async block without JSPI support in WebAssembly`. +The runtime must support JSPI too (see above). + ## Limitations These come from the Scala.js Wasm backend itself, not from Scala CLI: diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index a5d544d998..91e78089d4 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1393,6 +1393,10 @@ Enable jsdom Enable Wasm output (Scala.js Wasm backend). Uses Node.js by default. To show more options for Wasm pass `--help-wasm` +### `--js-wasm-jspi` + +Use JavaScript Promise Integration (JSPI) in Wasm output, required by js.async/js.await (Scala.js 1.22+) + ### `--js-runtime` JS runtime to run the output on: node (default), deno, bun diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 443c749de3..906974427e 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -751,12 +751,18 @@ Add WebAssembly options `//> using wasm` +`//> using wasmJspi` _true|false_ + +`//> using wasmJspi` + `//> using jsRuntime` _node|deno|bun_ #### Examples `//> using wasm` +`//> using wasmJspi` + `//> using jsRuntime node` `//> using jsRuntime deno` From 53b52b034839b2241b20c0ab62dd13a55ebca182 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 15 Oct 2026 16:50:39 +0200 Subject: [PATCH 2/4] Ignore the --js-wasm-jspi integration test until scala-js-cli supports --useJSPI The bundled scala-js-cli (1.22.0.1) rejects --useJSPI, so the end-to-end test can only pass once VirtusLab/scala-js-cli#209 is released and scalaJsCli is bumped. The option parsing and directive are covered by unit tests. Co-Authored-By: Claude Opus 5.5 --- .../scala/cli/integration/RunScalaJsTestDefinitions.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala index 9c88229c0d..e09e6957d9 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala @@ -428,7 +428,10 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions => } } - test("Run Wasm with js.async/js.await via --js-wasm-jspi") { + // Ignored until the bundled Scala.js linker CLI understands --useJSPI + // (https://github.com/VirtusLab/scala-js-cli/pull/209); the released + // scala-js-cli rejects the flag. Enable it when scalaJsCli is bumped. + test("Run Wasm with js.async/js.await via --js-wasm-jspi".ignore) { val inputs = TestInputs( os.rel / "Async.scala" -> """import scala.scalajs.js From ef0caa31d72771b2357b10e157d60f48bd413f27 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Fri, 16 Oct 2026 08:38:05 +0200 Subject: [PATCH 3/4] Enable the --js-wasm-jspi integration test Skip it on Scala 3.3, whose compiler has no js.async/js.await support. Co-Authored-By: Claude Opus 5.5 --- .../RunScalaJsTestDefinitions.scala | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala index e09e6957d9..98a8bfab29 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala @@ -428,38 +428,38 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions => } } - // Ignored until the bundled Scala.js linker CLI understands --useJSPI - // (https://github.com/VirtusLab/scala-js-cli/pull/209); the released - // scala-js-cli rejects the flag. Enable it when scalaJsCli is bumped. - test("Run Wasm with js.async/js.await via --js-wasm-jspi".ignore) { - val inputs = TestInputs( - os.rel / "Async.scala" -> - """import scala.scalajs.js - | - |object Async { - | def main(args: Array[String]): Unit = - | js.async { - | val x = js.await(js.Promise.resolve[Int](21)) - | println(s"JSPI ${x * 2}") - | } - |} - |""".stripMargin - ) - inputs.fromRoot { root => - val output = os.proc( - TestUtil.cli, - "--power", - "run", - "Async.scala", - "--js-emit-wasm", - "--js-module-kind", - "es", - "--js-wasm-jspi", - extraOptions - ).call(cwd = root).out.trim() - expect(output == "JSPI 42") + // The Scala 3.3 compiler has no js.async/js.await support (linking fails with + // "Referring to non-existent method scala.scalajs.js.package$.async"). + if (!actualScalaVersion.startsWith(Constants.scala3LegacyLtsPrefix)) + test("Run Wasm with js.async/js.await via --js-wasm-jspi") { + val inputs = TestInputs( + os.rel / "Async.scala" -> + """import scala.scalajs.js + | + |object Async { + | def main(args: Array[String]): Unit = + | js.async { + | val x = js.await(js.Promise.resolve[Int](21)) + | println(s"JSPI ${x * 2}") + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Async.scala", + "--js-emit-wasm", + "--js-module-kind", + "es", + "--js-wasm-jspi", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "JSPI 42") + } } - } test("Wasm without an ES module kind fails fast") { val inputs = TestInputs( From 98e8ec4399b413bcb974a89102ca6f2047624163 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Tue, 20 Oct 2026 20:31:54 +0200 Subject: [PATCH 4/4] Update modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala Co-authored-by: Piotr Chabelski --- .../scala/cli/integration/RunScalaJsTestDefinitions.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala index 98a8bfab29..743bdf30c8 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala @@ -428,9 +428,9 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions => } } - // The Scala 3.3 compiler has no js.async/js.await support (linking fails with + // js.async/js.await support was only introduced in Scala 3.8+ (earlier, linking fails with // "Referring to non-existent method scala.scalajs.js.package$.async"). - if (!actualScalaVersion.startsWith(Constants.scala3LegacyLtsPrefix)) + if isScala38OrNewer then test("Run Wasm with js.async/js.await via --js-wasm-jspi") { val inputs = TestInputs( os.rel / "Async.scala" ->