Skip to content

Add compiler lowering for Compose HTML EAP SSR - #5769

Open
Clemens Koprolin (clemenskoprolin) wants to merge 1 commit into
masterfrom
clemenskoprolin/compose-html-ssr-lowering
Open

Clemens Koprolin (clemenskoprolin) wants to merge 1 commit into
masterfrom
clemenskoprolin/compose-html-ssr-lowering

Conversation

@clemenskoprolin

@clemenskoprolin Clemens Koprolin (clemenskoprolin) commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Introduces a JVM compiler plugin that prepares static HTML at compile time and replaces supported composables with direct writer calls. Dynamic values remain escaped at runtime, unsupported calls retain normal rendering.

E.g.:

@Composable
fun Welcome() {
    Div {
        H1 { Text("Hello!") }
        P { Text("Welcome to Compose HTML.") }
    }
}

Becomes the equivalent of:

@Composable
fun Welcome() {
    WriteStaticHtml("div", "<div><h1>Hello!</h1><p>Welcome to Compose HTML.</p></div>")
}

With dynamic text instead:

@Composable
fun Greeting(name: String) {
    Div {
        Span { Text("Hello, ") }
        Text(name)
    }
}

it becomes equivalent of:

@Composable
fun Greeting(name: String) {
    val writer = currentHtmlStringWriter()
    val strict = currentHtmlValidationIsStrict()
    val parent = writer.openLoweredRegion(
        rootTag = "div",
        encoded = "<div><span>Hello, </span>",
        innermost = "div",
        validate = strict,
    )
    writer.writeText(name, strict)
    writer.closeElement("</div>", parent)
}

Supported lowering includes:

  • Static HTML/SVG subtrees and constant repeat bodies → precomputed HTML literals.
  • Mixed static markup and dynamic text → direct writer calls with runtime escaping.
  • Constant and dynamic attributes, classes, styles and form values → prepared attributes or direct serialization.
  • Built-in wrappers, direct TagElement calls and custom wrappers annotated with @LowerableHtmlTag → optimized writer calls where supported.
  • Supported constant expressions and immutable aliases → compile-time values

Includes a Gradle plugin, shared serialization rules and JVM bytecode and browser hydration tests. Prepared static HTML can retain text-boundary comments when hydratable = false.

Involves CMP-10991 and CMP-10836

Testing

Run from the html directory:

./gradlew -p eap/compiler-plugin -Pcompose.html.eap.enabled=true \
  check \
  :html:html-core-eap:jvmTest \
  :html:html-core-eap:jsTest \
  :html:html-core-eap:wasmJsTest

Release Notes

N/A

Prepare static HTML and attributes at compile time, lower supported elements to direct writer calls and provide a Gradle plugin with JVM and browser integration coverage.

val sharedSerializationSources = tasks.register<Sync>("syncSerializationSources") {
from("../core/src/commonMain/kotlin") {
include("org/jetbrains/compose/web/dom/HtmlSerializationRules.kt")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When editing those files, it's not obvious that they are used here in the compiler plugin implementation.

What do you think about extracting html-serialization-related helpers into a separate module and adding it as a usual dependency here?
The problem is that most of the helpers are "internal" and ideally they remain "internal". But for our needs it's fine to suppress the usages.

* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/

pluginManagement {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could the compiler plugin be included directly in the root settings?
Any reason why it's a standalone gradle project?

*
* @Composable
* fun UserName(name: @Composable () -> String) {
* PreparedTextElement(__htmlAttributes0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why not write directly to HtmlStringWriterContext ? Why is the field necessary?

* }
* ```
*/
abstract class HtmlLoweringExtension {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's keep just one property?

val enabledTargets: SetProperty< KotlinPlatformType>

With Jvm enabled by default

assertEquals(
"<span id=\"greeting\" title=\"&quot;&lt;&amp;&gt;&#13;&#10;&#9;😀\"" +
" data-key=\"value\" class=\"label accent last\">text</span>",
composeHtmlToString {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I see, this and some other tests pass even when the compiler plugin is disabled.

After this PR, I think it's worth to implement tests comparing the generated IR with the golden one.
Here is an example from Compose compiler plugin tests - https://github.com/JetBrains/kotlin/blob/de4aa1dfb1546fc83006f5833c5dce24948ebfc3/plugins/compose/compiler-hosted/integration-tests/testResources/golden/androidx.compose.compiler.plugins.kotlin.ClassStabilityTransformTests/testComposableCall.txt

Seeing such IR golden snippets help understand what the compiler plugin is supposed to do in every case.

}

internal inline fun forEachRenderMode(test: (strict: Boolean, hydratable: Boolean, stream: Boolean) -> Unit) {
for (strict in listOf(false, true)) for (hydratable in listOf(false, true)) for (stream in listOf(false, true)) {

@eymar Oleksandr Karpovich (eymar) Oct 26, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please reformat the files in this PR?

https://www.jetbrains.com/help/idea/reformat-and-rearrange-code.html#reformat_file (Cmd+option+shift+L)

It won't reformat these nested loops, so to make it more readable could you please format it manually?

internal inline fun forEachRenderMode(test: (strict: Boolean, hydratable: Boolean, stream: Boolean) -> Unit) {
    val variants = listOf(false, true)
    for (strict in variants) {
        for (hydratable in variants) {
            for (stream in variants) {
                test(strict, hydratable, stream)
            }
        }
    }
}

Perhaps in other places too.

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.

2 participants