Skip to content

[K/JS]: Implement support for companion blocks and extensions#6165

Draft
Andrii Rublov (seclerp) wants to merge 6 commits into
masterfrom
rr/rublov/companion-blocks
Draft

[K/JS]: Implement support for companion blocks and extensions#6165
Andrii Rublov (seclerp) wants to merge 6 commits into
masterfrom
rr/rublov/companion-blocks

Conversation

@seclerp

@seclerp Andrii Rublov (seclerp) commented Jun 5, 2026

Copy link
Copy Markdown
Member

The PR adds support for companion blocks and extensions feature for the Kotlin/JS backend, following the specification defined in the corresponding KEEP.

Since Kotlin/JS already supports proper lowering for static function and property declarations, main work in this PR has been made around static properties initializers in classes, especially preserving initialization order defined by a KEEP from above.

In short, main rules are the following:

  1. Initialization of properties inside class is defined by the order of initializers in the source code, regardless of the containing declaration of initializer. For example, in the above code the order of the initializers invocation is 1, 2, 3, regardless of initializer coming from companion block or companion object:
class Foo {
  companion {
    val first = initFirst()
  }
  companion object {
    val second = initSecond()
  }
  companion {
    val third = initThird()
  }
}
  1. When a class has a parent with its own static initializers, they should be invoked before any of child initializers are invoked.

The implementation of such an ordering guarantee is split between 2 new passes - JsStaticInitializersLowering and JsStaticInitializersInheritanceLowering.

JsStaticInitializersLowering creates a new static function attached to a class, called static_init. The body of that function contains all initializer statements in a proper order. The static_init function is always called once per class.

There are 2 cases with special handling:

  1. Companion object initializers logic is relocated from _getInstance function created by ObjectDeclarationLowering, while _getInstance now delegates to static_init call. This also ensures that accessing the companion instance will trigger other static initializers.
  2. Enum cases initializers are always initialized before any other initializers, so they are always put first.

JsStaticInitializersInheritanceLowering injects parent static_init call into the beginning of the child static_init body. It is done in a separate lowering due to a fact that not all classes would have static_init added to them, so we first need to create them where needed and only after that make proper child-parent propagations.

A simplified codegen example:

// main.kt
var initOrder = ""

open class Parent {
    companion {
        val parentBlock1: String = run {
            initOrder += "PB1"
            "pb1"
        }
    }
    companion object {
        val parentObj: String = run {
            initOrder += "PO"
            "po"
        }
    }
    companion {
        val parentBlock2: String = run {
            initOrder += "PB2"
            "pb2"
        }
    }
}

class Child : Parent() {
    companion {
        val childBlock1: String = run {
            initOrder += "CB1"
            "cb1"
        }
    }
    companion object {
        val childObj: String = run {
            initOrder += "CO"
            "co"
        }
    }
    companion {
        val childBlock2: String = run {
            initOrder += "CB2"
            "cb2"
        }
    }
}

Produces:

var Parent$static_init_called;
function Parent$static_init() {
  if (Parent$static_init_called)
    return Unit_instance;
  Parent$static_init_called = true;
  // Inline function 'kotlin.run' call
  Companion_getInstance();
  initOrder = initOrder + 'PB1';
  parentBlock1 = 'pb1';
  if (Companion_instance == null)
    new Companion();
  // Inline function 'kotlin.run' call
  Companion_getInstance();
  initOrder = initOrder + 'PB2';
  parentBlock2 = 'pb2';
}
function get_parentBlock1() {
  Parent$static_init();
  return parentBlock1;
}
var parentBlock1;
function Companion() {
  Companion_instance = this;
  var tmp = this;
  // Inline function 'kotlin.run' call
  initOrder = initOrder + 'PO';
  tmp.a_1 = 'po';
}
var Companion_instance;
function Companion_getInstance() {
  Parent$static_init();
  return Companion_instance;
}
function get_parentBlock2() {
  Parent$static_init();
  return parentBlock2;
}
var parentBlock2;
var Child$static_init_called;
function Child$static_init() {
  if (Child$static_init_called)
    return Unit_instance;
  Child$static_init_called = true;
  Parent$static_init();
  // Inline function 'kotlin.run' call
  Companion_getInstance_0();
  initOrder = initOrder + 'CB1';
  childBlock1 = 'cb1';
  if (Companion_instance_0 == null)
    new Companion_0();
  // Inline function 'kotlin.run' call
  Companion_getInstance_0();
  initOrder = initOrder + 'CB2';
  childBlock2 = 'cb2';
}
function get_childBlock1() {
  Child$static_init();
  return childBlock1;
}
var childBlock1;
function Companion_0() {
  Companion_instance_0 = this;
  var tmp = this;
  // Inline function 'kotlin.run' call
  initOrder = initOrder + 'CO';
  tmp.b_1 = 'co';
}
var Companion_instance_0;
function Companion_getInstance_0() {
  Child$static_init();
  return Companion_instance_0;
}
function get_childBlock2() {
  Child$static_init();
  return childBlock2;
}
var childBlock2;

Fixes KT-85459

@seclerp

Copy link
Copy Markdown
Member Author

/dry-run

@KotlinBuild

Build Server (KotlinBuild) commented Jun 8, 2026

Copy link
Copy Markdown

THIS IS A DRY RUN

Quality gate is triggered at https://buildserver.labs.intellij.net/build/968015790 — use this link to get full insight.

Quality gate was triggered with the following revisions:

kotlin
Branch: refs/merge/GITHUB-6165/safe-merge
Commit: c50656a


Quality gate finished successfully.

private fun processDeclarationContainer(container: IrClass) {
if (container.isExpect || container.isEffectivelyExternal()) return
val builder = context.irBuiltIns.createIrBuilder(container.symbol, SYNTHETIC_OFFSET, SYNTHETIC_OFFSET)
val staticDeclarationsByFields = buildMap {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

TBH I don't understand why we need this map. We can filter out static fields in the loop below, where we populate initializers. As for the staticDeclarationsByFields.isNotEmpty() check — it could be replaced with container.declarations.any { /*whatever logic we use when building the map*/ } (hoisted out of the loop, obviously).

The benefit of this is that we don't have to waste memory on the map.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not fixed

container.initEntryInstancesFun?.let { initEntries ->
// Replace the call to a _initEntries function to a static_init call. This ensures touching enum entries will trigger
// static initializers too.
container.parent.transformChildrenVoid(object : IrElementTransformerVoidWithContext() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks really inefficient to me. Basically, if container.parent is an IrFile with a large number of enum classes in it, then for each such class we traverse the whole file again looking for IrCalls. This will be slow. Usually, we would have two lowerings for such cases: one would transform declarations, and another — their usages.

However, the whole static initializer generation logic seems awfully similar to the initEntryInstancesFun generation logic in EnumClassCreateInitializerLowering, so we could try to commonize it I guess?
And I don't mean it in the "extract it into a common routine" sense, I mean that we can try to delete EnumClassCreateInitializerLowering entirely, and instead only rely on generating static initializers for all static members alike, be it fields or enum entries.

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.

Yes, it totally makes sense to me too. Initially, after starting working on static initializers in common, I dug into enum initializer lowering and it looked exactly as a specific case of static initializers, due to a fact that we only had them as statics and haven't needed to commonize them before.

I will try to replace them with JsStaticInitializersLowering in that PR. In case it would take more time will also make a corresponding task for it. Thanks!

@broadwaylamb

Sergej Jaskiewicz (broadwaylamb) commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
  1. I would like to ask you to also add a stepping test (compiler/testData/debug/stepping) for companion block initialization.
  2. The KEEP mentions that in JS static initializers should be compiled to static initialization blocks in JS, do you plan to do it later, or it doesn't work with the desired semantics?

@seclerp

Copy link
Copy Markdown
Member Author
  1. The KEEP mentions that in JS static initializers should be compiled to static initialization blocks in JS, do you plan to do it later, or it doesn't work with the desired semantics?

Regarding static initialization blocks - we discussed it with Artem Kobzar (@JSMonk) and for now we will keep it as static_init function even in ES2020 target

@seclerp

Copy link
Copy Markdown
Member Author
  1. I would like to ask you to also add a stepping test (compiler/testData/debug/stepping) for companion block initialization.

Regarding this one, I'll make a separate PR for such a test, as I did with other tests previously.

// _getInstance then calls static_init instead.
declaration.objectGetInstanceFunction?.let { getInstance ->
val body = getInstance.body as? IrBlockBody ?: return@let
body.statements.let { statements ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: maybe using let is a bit too much here, val statements = body.statements reads better


override fun visitClass(declaration: IrClass) {
for (function in declaration.functions) {
if (declaration.staticInitializer != function) continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems like the whole for loop can be replaced with just declaration.staticInitializer?.let { /*add parent static initializer calls*/ }?

}

private fun createStaticInitCalledField(irClass: IrClass): IrField = context.irFactory.buildField {
name = Name.identifier("${irClass.name.identifier}$$STATIC_INIT_CALLED_PROPERTY_NAME")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actually, I think that prefixing the field name with the name of the class is not needed, since it's generated inside the class anyway.

@seclerp Andrii Rublov (seclerp) Jun 9, 2026

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.

It is indeed generated inside the class, but there might be cases of lifting such declaration up, to the top level, which will introduce weird static_init_N styled names, like we for example have for Companion_N. It is clearly visible in todays tests

WDYT about thinking of updating lifting lowerings to somehow reflect the original container name in them instead of wiping it out?

startOffset = SYNTHETIC_OFFSET
endOffset = SYNTHETIC_OFFSET
this.origin = origin
name = Name.identifier(container.name.identifier + '$' + STATIC_INIT_FUNCTION_NAME)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same here.

// If static_init exists, it always contains at least 1 static field initializer
// TODO: The 'origin' has not been taken into account here because currently enum cases initializers do not set it to
// STATIC_FIELD_INITIALIZER
val initStartIndex = body.statements.indexOfFirst { it is IrSetField }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we have a test when an enum class implements some interface with a companion block? If not, let's add it to make sure the initialization order is correct.

private fun processDeclarationContainer(container: IrClass) {
if (container.isExpect || container.isEffectivelyExternal()) return
val builder = context.irBuiltIns.createIrBuilder(container.symbol, SYNTHETIC_OFFSET, SYNTHETIC_OFFSET)
val staticDeclarationsByFields = buildMap {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not fixed

@seclerp Andrii Rublov (seclerp) marked this pull request as draft June 9, 2026 21:42
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.

3 participants