Skip to content

Commit 9c1db69

Browse files
committed
Add blog post about the implementation of Scala.js support for Scala 3.
1 parent ad07433 commit 9c1db69

File tree

3 files changed

+186
-0
lines changed

3 files changed

+186
-0
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
---
2+
layout: blog-detail
3+
post-type: blog
4+
by: Sébastien Doeraene
5+
title: "Implementing Scala.js Support for Scala 3"
6+
---
7+
8+
Today, the JS platform has become an integral part of the language.
9+
Yet, until last August, support for Scala.js in Scala 3 was close to non-existent.
10+
A first preview was shipped in Dotty 0.27.0-RC1, with support for the portable subset of Scala and native JS types.
11+
Since then, support for non-native JS types was added and will ship as part of Scala 3.0.0-M1.
12+
The only missing feature left is JS exports, which we will implement by the next milestone.
13+
14+
What did it take to add Scala.js support in Scala 3?
15+
How come we managed to do the necessary work in less than 3 months, whereas Scala.js for Scala 2 took 7 years to mature?
16+
Those are questions we aim to explore in this post.
17+
After some background on the architecture of Scala.js for Scala 2, we will cover which components had to be rewritten for Scala 2, and which ones we could reuse.
18+
We will see that only a small fraction required a rewrite.
19+
20+
## Background: the Scala.js architecture
21+
22+
When we use Scala.js, it can sometimes appear a bit magical.
23+
In a simple sbt project, the only difference between a JVM project and a JS project can be as short as this one-liner:
24+
25+
```scala
26+
enablePlugins(ScalaJSPlugin)
27+
```
28+
29+
although for an application with a main method, we will also have
30+
31+
```scala
32+
scalaJSUseMainModuleInitializer := true
33+
```
34+
35+
Two settings, and suddenly `run` or `test` will compile the codebase to JavaScript and execute it on a JS engine such as Node.js, with high fidelity compared to Scala/JVM.
36+
37+
There are many components that contribute to this behavior.
38+
Just looking at the compilation pipeline, there are already 4 components involved: the compiler plugin for scalac, the linker, and the artifacts for the Scala standard library and the JDK subset that is implemented.
39+
40+
### A compiler plugin
41+
42+
When compiling a Scala.js codebase, the .scala source files are compiled with scalac, augmented with the Scala.js compiler plugin.
43+
From scalac, we receive parsing, type checking, elaboration (type inference, implicit resolution, overload resolution, etc.), as well as many transformation phases.
44+
The compiler plugin, comparatively to the rest of scalac, is small: it takes the internal compiler ASTs that have been lowered to contain JVM-style classes, interfaces, methods and instructions, and turns them into Scala.js IR (.sjsir files).
45+
46+
Thanks to everything it receives from scalac for free, the Scala.js compiler plugin does not have to deal with a lot of Scala-specific features.
47+
Traits have been lowered to interfaces; lazy vals have been desugared; nested classes, defs, and functions have been translated to top-level classes with methods; etc.
48+
This is how Scala.js is able to keep up-to-date with all the versions of Scala 2, and be available on the same day as new Scala releases.
49+
Not all compile-to-JS languages have that property!
50+
51+
### `.sjsir`files
52+
53+
.sjsir files are similar to .class files, although they are AST-based (instead of stack-machine-based) and contain features dedicated to JavaScript interoperability.
54+
The .sjsir format and specification is independent of Scala, its compiler or its standard library.
55+
In fact, there is nothing Scala-specific in the entire linker, other than a few optimizations targeted at Scala-like code.
56+
This is important because it means that the linker is independent of the version of Scala: we use the same linker irrespective of the version of Scala that was used to create the .sjsir files.
57+
In theory, we could even compile other JVM languages like Kotlin or Java to the Scala.js IR, then use our linker to emit JavaScript code.
58+
While we have a proof-of-concept for Java, it is nowhere near usable, however.
59+
60+
.sjsir files have binary compatibility guarantees similar to those of .class files.
61+
With a few exceptions related to JS interop annotations, if changes to a .class files are binary compatible, then the changes applied to the corresponding .sjsir files are also binary compatible.
62+
This allows Scala.js codebase to meaningfully use MiMa to check that they do not break their binary API.
63+
64+
The Scala.js linker (optimizer) takes the .sjsir files from the application and from all the transitive dependencies.
65+
In particular, the .sjsir files that are built for the Scala standard library and for the JDK subset.
66+
The linker optimizes all of them together, then emits one .js file for the entire codebase.
67+
68+
### Components of Scala.js core
69+
70+
The components involved here have the following sizes (not counting test files):
71+
72+
![Sizes of the components of Scala.js](/resources/img/blog/scalajs-for-scala-3/components-sizes.svg)
73+
74+
The two biggest components, "scalac compiler" and "Scala standard library", are not part of Scala.js itself.
75+
They are reused from the core Scala repository.
76+
77+
Among the components of Scala.js itself, the linker/optimizer and the subset of the JDK are the biggest, and account for 74% of the total.
78+
In comparison, the 8,000 lines of code of the Scala.js compiler plugin represent only 11% of the total 69 kLoC.
79+
80+
Add to that about 68,000 lines of testing code, and we get close to 140,000 lines of code for [the Scala.js core repository](https://github.com/scala-js/scala-js).
81+
For fun, we can compare that to the sizes of the repositories for Scala 2 and Scala 3:
82+
83+
![Sizes of the core Scala repositories](/resources/img/blog/scalajs-for-scala-3/repositories-sizes.svg)
84+
85+
It's worth pointing out that Scala 3 reuses the Scala standard library of Scala 2, so basically those two repositories have the same size.
86+
87+
## Supporting Scala.js in Scala 3
88+
89+
Scala 3 has an entirely new compiler codebase, reimplemented from scratch.
90+
While many internal concepts such as Trees, Types and Symbols exist in both compilers, their design, semantics, and internal APIs are quite different.
91+
The language itself has of course changed a lot as well.
92+
What does it mean for Scala.js support?
93+
94+
### A replacement for the compiler plugin
95+
96+
Clearly, we had to entirely replace the Scala.js compiler plugin to work with Scala 3.
97+
For convenience, we chose to implement the Scala.js support directly inside the Scala 3 compiler (dotc) rather than as an external compiler plugin.
98+
99+
Remember the 8,000 lines of code of the compiler plugin from above?
100+
Just like the compiler had been rewritten, we had to rewrite them for dotc.
101+
As of this writing, the amount of code related to Scala.js in dotc is about 6,000 lines.
102+
They cover virtually everything from the scalac compiler plugin; only JS exports support is missing.
103+
The implementation is easier (and shorter) in dotc due to several changes we made earlier in the internal data structures of dotc to make it more friendly to Scala.js.
104+
105+
This is essentially what we have been doing at the Scala Center for the past few months in order to add Scala.js support in Scala 3.
106+
As we will see, everything else was already there for us to reuse.
107+
108+
### Reusing the linker
109+
110+
We mentioned earlier that the Scala.js linker, written in Scala 2, links .sjsir files independently of the version of Scala that produced them.
111+
This is also true for Scala 3!
112+
As long as the Scala 3 compiler generates .sjsir files that respect their specification, they can be linked by the existing Scala.js linker published on Maven Central.
113+
114+
### Reusing the .sjsir files of the Scala standard library and of the JDK subset
115+
116+
As you may already know, a Scala 3 codebase is able to depend on libraries compiled by Scala 2.
117+
This is even exploited down to the standard library level: Scala 3 codebases use the standard library compiled by Scala 2.13.
118+
This is possible because the dotc compiler can read the "pickled" signatures in Scala 2 .class files, and it generates .class files that are binary compatible with those produced by Scala 2.
119+
120+
Recall that, if .class files are binary compatible, corresponding .sjsir files are binary compatible as well.
121+
Combined with the above properties, it means that Scala 3/JS codebases can reuse libraries compiled by Scala 2/JS.
122+
And among them, the Scala standard library and the JDK subset.
123+
Therefore, can also reuse the artifacts already published on Maven Central.
124+
125+
That property extends to the JUnit implementation, to the test bridging infrastructure, and even to any 3rd party library!
126+
127+
### Reusing the sbt plugin
128+
129+
Without too much detail, the Scala.js sbt plugin has 3 responsibilities:
130+
131+
* Adding the scalajs-library.jar on the classpath of the project and the Scala.js compiler plugin
132+
* Provide tasks to call the linker on the .sjsir files to produce .js files
133+
* Set up `run` and `test` to use the produced .js file and the testing infrastructure bridge
134+
135+
The last 2 of those responsibilities are independent of the Scala version.
136+
They are only concerned with .sjsir files, the linker, and the testing infrastructure, all of which can be reused.
137+
138+
As of v1.3.0, sbt-scalajs implements the first task for Scala 2 only.
139+
sbt-dotty v0.4.2 and later adapts it to Scala 3, so that users need not care about anything.
140+
For example, it removes the Scala 2 compiler plugin that sbt-scalajs adds, and instead adds the compiler flag `-scalajs`.
141+
When the functionality of sbt-dotty gets merged into sbt itself, sbt-scalajs will be adapted to directly handle the setup of Scala 3.
142+
143+
Thanks to the combination of sbt-scalajs and sbt-dotty, a straightforward build works out of the box with Scala 3 and Scala.js.
144+
A small example would be:
145+
146+
```scala
147+
// project/plugins.sbt
148+
addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.4.3")
149+
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.3.0")
150+
```
151+
152+
```scala
153+
// build.sbt
154+
ThisBuild / scalaVersion := "0.27.0-RC1"
155+
val myScala3JSProject = project
156+
.enablePlugins(ScalaJSPlugin)
157+
.settings(
158+
// Scala.js project with a main method
159+
scalaJSUseMainModuleInitializer := true,
160+
)
161+
```
162+
163+
### Reusing the tests
164+
165+
While reusing all of the above was definitely a huge time saver (several person·years), it is the reuse of tests that I valued the most during this work.
166+
Have you ever dreamed about implementing a significant system from scratch without having to write the tests, because the tests already exist?
167+
If yes, you understand what I mean!
168+
169+
In the dotty codebase, we pull the sources of the Scala.js test suite from the scala-js/scala-js repo, and we compile them with dotc with Scala.js support.
170+
We then link them and run the tests using the sbt plugin we mentioned above.
171+
This ensures that Scala.js in Scala 3 is compliant to all the test cases that we have for Scala 2 (and there are a lot of those).
172+
173+
Of course, we did have to make some changes here:
174+
175+
* Some tests used syntax or other features not available in Scala 3 anymore.
176+
As long as using that syntax/feature was not the point of the test, we changed the test in the Scala.js core repo to be compilable with Scala 3.
177+
* Tests that were specifically testing a Scala 2 feature or quirky behavior that does not exist in Scala 3 anymore were moved to a separate directory of Scala 2-only tests.
178+
Some of those received Scala 3-specific variants in the dotty repository (for example, reflective calls).
179+
180+
## Conclusion
181+
182+
We have explored how the various components of Scala.js fit in the Scala 3 landscape.
183+
In particular, we saw that we can reuse most of the hard work that went into Scala.js for Scala 2.
184+
We only had to reimplement the compiler plugin, which is comparatively small.

0 commit comments

Comments
 (0)