[kotlinx.html] Reusable component without extra div

I’m looking for an effective solution to create reusable components in kotlinx.html. I found three type of solutions, but each result in unwanted side effects, such as being 100x slower, or unwanted extra divs.

This is the code:

import kotlinx.html.*
import kotlinx.html.stream.appendHTML
import kotlinx.html.stream.createHTML
import java.util.UUID
import kotlin.system.measureTimeMillis

fun main() {
    repeat(1000) {
        val time = measureTimeMillis {
            renderMultipleProducts(products)
        }
        println(time)
    }
}

// Usecase A: rendering multiple products (With extra unwanted container div)
fun renderMultipleProducts(products: List<Product>): String {
    return createHTML().div { // this div is unwanted
        for (product in products) {
            // version 1
            productComponent1(product)()

            // version 2, 100x slower than version 1 and 3
            consumer.productComponent2(product)

            // version 3
            productComponent3(product)
        }
    }
}

// Usecase B: render single product (Without unwanted container div)
fun renderSingleProduct(product: Product): String {
    return buildString { // no extra div needed
        appendHTML().productComponent2(product)
    }
}

// Render using returned lambda receiver
fun productComponent1(product: Product): FlowContent.() -> Unit = {
    div {
        h5 { +product.name }
        div { +"$ ${product.price}" }
    }
}

// Render using TagConsumer extension function
fun TagConsumer<*>.productComponent2(product: Product) {
    div {
        h5 { +product.name }
        div { +"$ ${product.price}" }
    }
}

// Render using FlowContent/Tag extension function
fun FlowContent.productComponent3(product: Product) {
    div {
        h5 { +product.name }
        div { +"$ ${product.price}" }
    }
}

// Data
val products = (1..5000).map {
    Product(UUID.randomUUID().toString(), (Math.random() * 100000).toInt())
}

// Domain class
data class Product(val name: String, val price: Int)

Using fun TagConsumer<*>.productComponent2() allows me to render a reusable component without requiring additional container divs. But when I benchmarked it, I found that it was about 100 times slower then the two other solutions that I found.
The other solutions are fun productComponent1(product: Product): FlowContent.() -> Unit and fun FlowContent.productComponent3(product: Product), but they require the caller to wrap the calls in container tags, such as divs.

Is there a solution for this problem?

How are you benchmarking these results? Are you using something like kotlinx-benchmark? measureTimeMillis really isn’t that reliable, and you can’t benchmark multiple pieces of code at the same exact time. My educated guess would be that you should define an extension function that takes in the same receiver that the html block brings into scope, and inside of that you can call div however much you want.

1 Like

Okay, I’ve done some benchmarking, and the results are… interesting.
Firstly, here’s the code to the initial benchmark, and the improved one (more on that later).
I benchmarked both for single item and 5000 items.Using your code, with some slight tweaks, I got these results (higher is better):

Benchmark                                         (isSingleProductBenchmark)   Mode  Cnt       Score        Error  Units
KotlinxHtmlBenchmark.renderWithProductComponent1                        true  thrpt    5  526074.461 ± 104746.534  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent1                       false  thrpt    5     489.953 ±      6.626  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent2                        true  thrpt    5  521620.219 ±   9261.835  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent2                       false  thrpt    5       8.736 ±      0.126  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent3                        true  thrpt    5  525295.699 ±   2620.859  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent3                       false  thrpt    5     489.193 ±      3.260  ops/s

Clearly renderWithProductComponent2 is much much worse than the other functions at multiple products, but for single products the performance is basically the same.
Digging deeper into the root cause of that, we find that renderWithProductComponent2 uses a consumer, which, when you call an HTML dsl function on, “finalizes” the block (i.e. it converts it immediately to a string). This is what’s causing the terrible performance. It’s creating a separate string per product, while renderWithProductComponent1 and renderWithProductComponent3 aren’t.
Changing the code slightly to use buildString and Appendable.appendHtml (see the improved benchmark code above), we get these results:

Benchmark                                         (isSingleProductBenchmark)   Mode  Cnt        Score        Error  Units
KotlinxHtmlBenchmark.renderWithProductComponent1                        true  thrpt    5   528688.536 ±  20407.482  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent1                       false  thrpt    5      465.755 ±     34.461  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent2                        true  thrpt    5  3159395.398 ± 111562.183  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent2                       false  thrpt    5      505.506 ±      3.712  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent3                        true  thrpt    5   523158.969 ±  22718.656  ops/s
KotlinxHtmlBenchmark.renderWithProductComponent3                       false  thrpt    5      491.098 ±      2.633  ops/s

As you can see, all of them have comparable performance. But renderWithProductComponent2 is much faster at single-items and has the advantage of not needing an outer div block. Instead, it needs an Appendable to add the string to.

2 Likes

(removed, we replied at the same time :smiley: , but you were quicker, so I first need to read your post :slight_smile: )

We posted at the same time :grin: You basically need buildString to be on the outside so that all the products are added to it. This ensures that you don’t need thousands of String objects.
Edit: And we addressed it at the same time, but you were quicker :smiley: Take your time!