DEV Community

loading...
Cover image for Migrating our Kotlin/JS app to the new IR compiler
Kotlin

Migrating our Kotlin/JS app to the new IR compiler

sebastianaigner profile image Sebastian Aigner Updated on ・12 min read

Together with some colleagues, I maintain a small full-stack web application called CodeQuiz, which we built during a 48-hour hackathon at JetBrains, and use at events to gamify learning about Kotlin. I recently migrated its frontend which you can see below (and which is using the kotlin-react wrappers) to the new Kotlin/JS IR compiler backend.

CodeQuiz in action

The new compiler made a bunch of issues in our code visible, so I wanted to share my experience of migrating a Kotlin/JS app, and provide some hints on where to look when your app behaves unexpectedly after moving to the IR compiler.

What's the Kotlin/JS IR compiler?

The Kotlin/JS IR compiler is currently in development (with alpha stability) and on its way to become the new default way of compiling Kotlin to JavaScript. It's a completely re-engineered infrastructure for all things Kotlin/JS. This switch comes with a number of benefits for Kotlin/JS applications!

Using it allows you to already test drive a bunch of new features, including TypeScript declaration generation, and profit from new optimizations like stronger DCE (and, as a result, smaller generated artifacts).

But it also means that you have to embrace its more strict rules regarding interoperation between Kotlin and JavaScript. This might require some adjustment at first, but will help write more predictable code that interoperates between Kotlin and JavaScript.

Why doesn't my code just work? 😱

Especially with code at the "boundary" between Kotlin and JavaScript, the legacy compiler was quite lenient – for example how it exported all symbols (e.g. a data class) from Kotlin code to the JavaScript world.

Unfortunately, this means that it was easy to rely on compiler-specific internal behavior – some things just happened to work, even though the compiler gave no guarantees that these things were supposed to work.

When using the IR compiler these mistakes become visible – it enforces proper, explicit interoperation between the world of Kotlin and the world of JavaScript (we call this the "Closed World" model). This stricter and more explicit control will help the compiler optimize your code more aggressively.

But, due to the nature of JavaScript being a dynamic runtime environment, some of these changes in behavior only appear during execution time. In the case of CodeQuiz, a number of modifications were necessary to get everything working. We'll look at them in detail in the next sections.

Ultimately, it boiled down to running and testing the application (both in development and production mode), and keeping an eye on the following:

  • Helping Kotlin's DCE via @JsExport (e.g. React components)
  • Using external interface to define React properties (RProps) and state (RState) (instead of (data) classes) and other areas of interoperation
  • Creating plain JavaScript objects for interaction with external components
  • Fixing npm dependencies that use export default
  • Making sure our Kotlin dependencies support Kotlin/JS IR

Turning on IR

To use the IR compiler for our project, we make a small change to our build.gradle(.kts) file. In the kotlin configuration block, change js to js(IR), and enable the generation of JavaScript artifacts via binaries.executable():

js(IR) {
    binaries.executable()
    browser {
        commonWebpackConfig {
            cssSupport.enabled = true
        }
        testTask {
            useKarma {
                useChromeHeadless()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

(Alternatively, the compiler type can also be set in the gradle.properties file, with the key kotlin.js.compiler=ir, which might be easier if you have a more complex project.)

We can now cross our fingers and execute the browserDevelopmentRun Gradle task to start our application.

Let's look at some of the symptoms our CodeQuiz app exhibited when first running the application with IR, and let's correct the related code.

Make JS- and React-related classes external interfaces

The external modifier helps Kotlin understand that a certain declaration is pure JavaScript. This prevents problems like ClassCastExceptions that would arise from the false assumption that something is a Kotlin object (like a data class) – even though in reality, we are dealing with a plain JavaScript object.

When using react-kotlin, this can often be observed regarding definitions of RState and RProps – with React, state and properties are pure JavaScript objects managed by the framework for us.

Turn RState into an external interface

When running my application with IR enabled for the first time, I got the following ClassCastException in regards to some React components' init method:

codequiz-server.js?20e3:19131 Uncaught 
ClassCastException {message: undefined, cause: undefined, name: "ClassCastException", stack: "ClassCastException↵    at THROW_CCE (webpack-inter…s/react-dom/cjs/react-dom.development.js:4056:31)"}
cause: undefined
message: undefined
name: "ClassCastException"
stack: "ClassCastException↵    at THROW_CCE (webpack-internal:///./kotlin/codequiz-server.js:19101:11)↵    at App.init (webpack-internal:///./kotlin/codequiz-server.js:101164:69)↵    at RComponent_init_$Init$ (webpack-internal:///./kotlin/codequiz-server.js:31545:11)↵    at new App (webpack-internal:///./kotlin/codequiz-server.js:101148:5)↵    at constructClassInstance (webpack-internal:///../../node_modules/react-dom/cjs/react-dom.development.js:12716:18)↵    at updateClassComponent (webpack-internal:///../../node_modules/react-dom/cjs/react-dom.development.js:17425:5)↵    at beginWork (webpack-internal:///../../node_modules/react-dom/cjs/react-dom.development.js:19073:16)↵    at HTMLUnknownElement.callCallback (webpack-internal:///../../node_modules/react-dom/cjs/react-dom.development.js:3945:14)↵    at Object.invokeGuardedCallbackDev (webpack-internal:///../../node_modules/react-dom/cjs/react-dom.development.js:3994:16)↵    at invokeGuardedCallback (webpack-internal:///../../node_modules/react-dom/cjs/react-dom.development.js:4056:31)"
__proto__: RuntimeException
THROW_CCE   @   codequiz-server.js?20e3:19131
App.init    @   codequiz-server.js?20e3:101224

Enter fullscreen mode Exit fullscreen mode

The stack trace suggests the init method of my App component. Since here, only application state is initialized, it was quite easy to pinpoint the underlying problem.

The offending code for the application state looks like this:

interface AppState : RState {
    var isPresenter: Boolean
    var lastMessage: Content?
    var isConnected: Boolean
    var chosenName: String?
}
Enter fullscreen mode Exit fullscreen mode

This code happened to work with the legacy compiler, but the IR compiler marks our problem: if our interface describes the exact shape of a JavaScript object, we need to mark the interface as external.

The refactored code looks like this:

external interface AppState : RState {
    // . . .
Enter fullscreen mode Exit fullscreen mode

I made sure that all interfaces implementing RState in my application were annotated with external by using a structural search and replace. If you're using IntelliJ IDEA 2021.1, you can copy an SSR template I prepared into your clipboard. To use it, open SSR via File | Find | Find Structurally [or Replace Structurally], click on the wrench icon, and select "Import Template from Clipboard". You can then click "Find" and "Replace All" to annotate all interfaces properly.

Turn RProps into an external interface

RState isn't the only type that is affected by this change – similar problems appear when React properties (RProps) aren't marked as external:

codequiz-server.js?20e3:100446 Uncaught TypeError: $this$attrs._set_presenterStartGameHandler_ is not a function
    at _no_name_provided__346.invoke_547 (codequiz-server.js?20e3:100446)
    at eval (codequiz-server.js?20e3:101430)
    at RElementBuilder.attrs_0 (codequiz-server.js?20e3:31443)
Enter fullscreen mode Exit fullscreen mode

Analogously, this results from the RProps definition being just a Kotlin interface:

interface LobbyProps : RProps {
    var isPresenter: Boolean
    var presenterStartGameHandler: () -> Unit
    var playerLoginHandler: (String) -> Unit
    var playerList: PlayerList?
    var isDisabled: Boolean
}
Enter fullscreen mode Exit fullscreen mode

The IR-approved versions of this code uses an external interface:

external interface LobbyProps : RProps {
    // . . .
Enter fullscreen mode Exit fullscreen mode

Once again, this change can just be repeated for all components defining RProps interfaces in the Kotlin/JS application. This is easily automated via structural search and replace, as described in the previous section. Here is a template for auto-annotating your RProps as external – instructions for using SSR can be found in the previous section.

Use external interfaces over data classes!

If you've been using Kotlin's class or data class to create your RProps or RStates, you will need to do a similar refactoring. Code like this is invalid when using Kotlin/JS IR:

data class CustomComponentState(
   var name: String
) : RState
Enter fullscreen mode Exit fullscreen mode

Instead, use the following, refactored version.

external interface CustomComponentState: RState {
   var name: String
}
Enter fullscreen mode Exit fullscreen mode

Address limitations of external interfaces

Compared to a Kotlin interface or class, there are a few limitations when using external interface.

If you want to instantiate the interface from Kotlin code, you will have to mark your properties as var (val will not work here). Also, certain Kotlin-specific constructs, such as function types with receivers, are prohibited in external declarations.

In our codebase, the latter showed up as a compile error in an interface called ButtonProps. Here, we define a property inside which takes an extension function on the StyledDOMBuilder type to define any components that should be rendered in the button:

external interface ButtonProps : RProps {
    var inside: StyledDOMBuilder<BUTTON>.() -> Unit
    // . . .
}
Enter fullscreen mode Exit fullscreen mode

Since these functions with receivers are just syntactic sugar for a function with an (implicitly named) parameter of the same type, we can refactor the external interface and pass the StyledDOMBuilder explicitly, resolving this problem:

var inside: (StyledDOMBuilder<BUTTON>) -> Unit
Enter fullscreen mode Exit fullscreen mode

As luck would have it, our callsite was already structured so that this slightly changed style of function definition just works, so no change was needed there:

styledButton {
    props.inside(this)

    attrs {
        // . . .
    }
}
Enter fullscreen mode Exit fullscreen mode

Create plain JS objects for interoperability

Inside the definition of a React component, objects implementing RState and RProps already exist, and we simply modify their properties.

When we create these objects ourselves, we (currently still) need to be a bit careful. In CodeQuiz, we had the following problem passing values to an external react-minimal-pie-chart component:

PieChart.default {
    attrs {
        data = props.statistics.answers.mapIndexed { index, (_, answerCounts) ->
            object: PiePoint {
                override var title = "Number $index"
                override var value = answerCounts
                // . . .
            }
        }.toTypedArray()
    }
}
Enter fullscreen mode Exit fullscreen mode

...and that even though PiePoint is correctly specified as an external interface. The specific issue here turned out to be a bit finicky:

As of now, properties on a Kotlin object implementing an external interface are accessible from JavaScript, but, for example, they are not enumberable. react-minimal-pie-chart internally uses Object.assign to copy around some of the data we pass as props. It loses our non-enumerable properties in the process, which leads to some unexpected undefineds at runtime.

Until this problem is resolved (see the corresponding YouTrack issue), the safe route right now is to generate plain JavaScript objects ourselves.

The kotlin-wrappers actually include a helper function called jsObject<T> which is useful for creating such objects. The same snippet using these plain JavaScript objects looks like this:

PieChart.default {
    attrs {
        data = props.statistics.answers.mapIndexed { index, (_, answerCounts) ->
            jsObject<PiePoint> {
                title = "Number $index"
                value = answerCounts
                // . . .
            }
        }.toTypedArray()
    }
}
Enter fullscreen mode Exit fullscreen mode

Since in a plain JavaScript object, all properties are enumerable, our charting library now works properly.

Help the DCE via @JsExport!

Dead Code Elimination (DCE) is the part of the Kotlin/JS IR compiler that helps keep your compiled production artifacts small. It's responsible for analyzing the Kotlin code for any pieces of code that aren't being used anywhere, and subsequently deleting them.

When packaging our application for production (which is when DCE is executed, e.g. via browserProductionRun or jsBrowserDistribution), this can present a problem for our React components.

Consider the following Evaluation class from our project:

class Evaluation(l: EvaluationProps) : RComponent<EvaluationProps, RState>(l) {
    override fun RBuilder.render() {
Enter fullscreen mode Exit fullscreen mode

The only way this class is ever referenced via its KClass, when we tell React to render this component:

child(Evaluation::class) {
    attrs {
            // . . .
Enter fullscreen mode Exit fullscreen mode

As of now, the IR DCE tries to be a bit too clever for its own good. It removes the contents of our class practically entirely (from its perspective, none of it, besides the type itself, is being used after all!). This causes the (unfortunately quite cryptic) error TypeError: r.render is not a function (or something similar).

To turn this error message into something a bit more actionable, we can (temporarily!) enable webpack's development mode in our Gradle build file (build.gradle(.kts)), which turns off the name minification:

browser {
    commonWebpackConfig {
        // . . .
        mode = org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig.Mode.DEVELOPMENT
    }
}
Enter fullscreen mode Exit fullscreen mode

For now, we need to make sure our component doesn't get removed, we can mark the class with @JsExport. Then, DCE will not touch it:

@JsExport
class Evaluation(l: EvaluationProps) : RComponent<EvaluationProps, RState>(l) {
    override fun RBuilder.render() {
Enter fullscreen mode Exit fullscreen mode

(As a small sidenote: declarations marked as external, such as an external interface, are always treated as reachable by DCE, and don't need this treatment. Functional components are also not affected, because their usage site doesn't refer to the ::class, but to the variable holding the component directly.)

In the case of kotlin-react, there are still some rough edges, like the warning Exported declaration uses non-exportable super type: RComponent. Together with making this kind of "workaround" obsolete, these are topics that still need addressing before the IR compiler becomes the default choice.

You can find a Structural Search and Replace template for this change right here. Find instructions on how to apply this automated replacement to your project in one of the previous paragraphs.

This is definitely one of the trickier issues to find, because it only manifests in production artifacts (when DCE is actually executed). For this reason, it's important to test your production artifacts!

Fixing dependencies on default exports

Our app uses a few external React components which we get from npm, including react-minimal-pie-chart.

Module parse failed: Unexpected keyword 'default' (35:6)
File was processed with these loaders:
 * ../../node_modules/source-map-loader/dist/cjs.js
You may need an additional loader to handle the result of these loaders.
|   var render = $module$react_dom.render;
|   var createGlobalStyle = $module$styled_components.createGlobalStyle;
>   var default = $module$react_minimal_pie_chart.default;
|   var default = $module$react_player.default;
|   'use strict';
Enter fullscreen mode Exit fullscreen mode

We wrote the following external declaration for the component provided by this package, which worked for our used version, 5.0.2, beforehand, but not with IR:

@file:JsModule("react-minimal-pie-chart")
@file:JsNonModule
external interface PieChartProps: RProps {
    // . . .
}

@JsName("default")
external val PieChart: RClass<PieChartProps>
Enter fullscreen mode Exit fullscreen mode

Here, we actually hit a bug in the IR compiler! It currently does not treat default as a reserved identifier. This causes a conflict when the library uses this identifier for its exports:

import Chart from './Chart';
export default Chart;
Enter fullscreen mode Exit fullscreen mode

An issue exists to turn default into a reserved identifier, and this point will hopefully be addressed soon. Until then, the workaround is to wrap the definition in an external object, like so:

external interface PieChartProps : RProps {
    // . . .
}

@JsModule("react-minimal-pie-chart")
@JsNonModule
external object PieChart {
    val default: RClass<PieChartProps>
}
Enter fullscreen mode Exit fullscreen mode

At the usage site for the component, we now use the PieChart.default value instead of the PieChart value previously:

PieChart.default {
    attrs {
        // . . .
    }
}
Enter fullscreen mode Exit fullscreen mode

Fix library code

After fixing all of the other problems, I noticed a special case where the app would throw the following error:

Uncaught Error: `props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. Please visit https://reactjs.org/link/dangerously-set-inner-html for more information.
Enter fullscreen mode Exit fullscreen mode

It took me a while to find the culprit, but I remembered that there was a place where we explicitly allowed HTML-formatted rich text in our application, and are using unsafe:

val label: RBuilder.() -> Unit = {
    span {
        attrs.unsafe {
            +answerText
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It turns out that kotlin-wrappers actually contained a small mistake in its own interoperation code: it used a class instead of an external interface for their InnerHTML object – which is used to implement attrs.unsafe.

Make InnerHTML external interface #416

Fixes IR problem where unsafe throws

`props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`

https://youtrack.jetbrains.com/issue/KT-45021

This was a great point to make a small open-source contribution in the form of a pull request (and get the code improved further just hours later)!

Petition library authors to support IR

Luckily, all the libraries we are using in the project (including Ktor Clients and kotlinx.serialization) already support the Kotlin/JS IR compiler, and they provide artifacts that work with both backends. And there is a number of other libraries that already offer artifacts compatible with the IR compiler, like fritz2, KorGE, Kodein-DI, and more.

If you're using a Kotlin/JS library that currently does not ship IR-compatible artifacts, it might be a good idea to catch the maintainer's attention, and maybe help out yourself to ensure that your favorite libraries work well with the new compiler infrastructure. To make sure libraries can support both legacy and IR backends at the same time, there is also a mechanism for authoring libraries with backwards compatibility.

If you're a library author, and want to learn more about supporting the Kotlin/JS IR backend, please do not hesitate to reach out on the Kotlinlang Slack. You can either contact me directly, or get input from the team and community in the #javascript channel.

Closing thoughts

The new IR compiler introduces some changes that might require action from you – especially in places where Kotlin code meets the JavaScript platform. I hope this post helps diagnose some of these behavior changes, so that you can experience all the exciting stuff the new compiler brings as soon as possible.

If you encounter issues during your migration to the IR backend, share them with the team. We're happy to help, and rely on your feedback to make sure we can iron out any remaining problems as soon as possible. The easiest way to do this is to log your problems in the official Kotlin issue tracker.

Give the Kotlin/JS IR compiler a try in your projects, and prepare yourself for the future!

Discussion (0)

pic
Editor guide