Skip to content

Instantly share code, notes, and snippets.

@gzm0
Last active October 15, 2020 14:40
Show Gist options
  • Save gzm0/5a4e6e3695893e5844d638762fa58da9 to your computer and use it in GitHub Desktop.
Save gzm0/5a4e6e3695893e5844d638762fa58da9 to your computer and use it in GitHub Desktop.

Module Splitting

Quickstart

Instead of fastOptJS / fullOptJS, use fastLinkJS / fullLinkJS and do any or all of the following:

For many small modules

import org.scalajs.linker.interface.ModuleSplitStyle
scalaJSLinkerConfig ~= (_.withModuleSplitStyle(ModuleSplitStyle.SmallestModules))

For different entry points

Set the moduleID for your top level exports and/or module initializers explicitly. The default moduleID is "main".

@JSExportTopLevel(name = "startAdmin", moduleID = "admin")
def startAdmin(): Unit = ???
import org.scalajs.linker.interface.ModuleInitializer
scalaJSModuleInitializers in Compile += {
  ModuleInitializer.mainMethod("my.app.admin.Main", "main")
    .withModuleID("admin")
}

Everything with the same moduleID will go into the same entry point module.

What is Module Splitting?

With module splitting, the Scala.js linker splits its output into multiple JavaScript modules (i.e. files). Both ES6 modules (ModuleKind.ESModule) and CommonJS modules (ModuleKind.CommonJSModule) are supported.

There are several reasons to split JavaScript output into multiple files:

  • Share code between different parts of an application (e.g. user/admin interfaces).
  • Create smaller files to minimize changes for incremental downstream tooling.
  • Load parts of a large app progressively (not supported yet, see #4201).

The Scala.js linker can split a full Scala.js application automatically based on:

  • The entry points (top level exports and module initializers)
  • The split style (fewest modules or smallest modules)

Entry Points

Scala.js generated code has two different kinds of entry points:

  • Top level exports: Definitions to be called from external JS code.
  • Module initializers: Code that gets executed when a module is imported.

The Scala.js linker determines how to group entry points into different (public) modules by using their assigned moduleID. The default moduleID is "main".

The moduleID of a top level export can be specified using the moduleID parameter. The moduleID of a ModuleInitializer can be specified by the withModuleID method.

Example:

Say you have the following App.scala and build.sbt:

package my.app

import scala.collection.mutable
import scala.scalajs.js.annotation._

// Separate objects to allow for splitting.

object AppA {
  @JSExportTopLevel(name = "start", moduleID = "a")
  def a(): Unit = println("hello from a")
}

object AppB {
  private val x = mutable.Set.empty[String]

  @JSExportTopLevel(name = "start", moduleID = "b")
  def b(): Unit = {
    println("hello from b")
	println(x)
  }
  
  def main(): Unit = x.add("something")
}
import org.scalajs.linker.interface.ModuleInitializer

scalaJSModuleInitializers in Compile += {
  ModuleInitializer.mainMethod("my.app.AppB", "main").withModuleID("b")
}

This would generate two public modules a.js / b.js. a.js will export a method named start that calls AppA.a. b.js will export a method named start that calls AppB.b. Further, importing b.js will call AppB.main.

Note that there is no public module main.js, because there is no entry point using the default moduleID.

Module Split Styles

So far, we have seen how public modules can be configured. Based on the public modules, the Scala.js linker generates internal modules for the shared code between the public modules. Unlike public modules, internal modules may not be imported by user code. Doing so is undefined behavior and subject to change at any time.

The linker genereates internal modules automatically based on the dependency graph of the code and moduleSplitStyle. You can change it as follows:

import org.scalajs.linker.interface.ModuleSplitStyle
scalaJSLinkerConfig ~= (_.withModuleSplitStyle(ModuleSplitStyle.SmallestModules))

There are currently two module split styles:

FewestModules

Create as few modules as possible without including unnecessary code. This is the default.

In the example above, this would generate:

  • a.js: public module, containing AppA and the export of start.
  • b.js: public module, containing AppB, mutable.Set, the export of start and the call to AppB.main
  • a-b.js: internal module, Scala.js core and the implementation of println.

This also works for more than two public modules, creating intermediate shared (internal) modules as necessary.

SmallestModules

Create modules that are as small as possible. The smallest unit of splitting is a Scala class.

Using this mode typically results in an internal module per class with the exception of classes that have circular dependencies: These are put into the same module to avoid a circular module dependency graph.

In the example above, this would generate:

  • a.js: public module, containing the export of start.
  • b.js: public module, containing the export of start and the call to AppB.main
  • many internal small modules (~50 for this example), approximately one per class.

Linker Output

With module splitting, the set of files created by the linker is not known at invocation time. To support this new requirement, the linker output is configured as follows:

  • A directory where all files go: scalaJSLinkerOutputDirectory
  • Patterns for output file names: outputPatterns on scalaJSLinkerConfig.

Both of these have reasonable defaults and usually do not need to be changed. The exception is file extensions: If you need to produce *.mjs files for Node.js, use:

import org.scalajs.linker.interface.OutputPatterns
scalaJSLinkerConfig ~= (_.withOutputPatterns(OutputPatterns.fromJSFile("%s.mjs")))

In order to make sense of the files in the directory, linking returns a Report listing the public modules and their file names.

sbt backwards compatibility

Since the fastOptJS / fullOptJS keys/tasks assume that linking will produce a single file, we had to introduce two new keys/tasks for linking: fastLinkJS / fullLinkJS. These tasks return the Report instead of an individual File.

In order to ensure backwards compatibility, the fastOptJS / fullOptJS tasks now invoke fastLinkJS / fullLinkJS respectively and copy the produced files to their target location. However, this only works if the linker produced a single public module. So with actual module splitting, fastOptJS / fullOptJS will fail.

The run and test tasks now depend on fastLinkJS / fullLinkJS (depending on the scalaJSStage) and load the public module with moduleID="main" (they fail if no such module exists). This does not change their behavior for existing builds but allows running and testing with module splitting enabled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment