Kotlin / kotlinx.html

Kotlin DSL for HTML
Apache License 2.0
1.61k stars 131 forks source link

Would it be possible to remove `crossinline`? #189

Open felixscheinost opened 2 years ago

felixscheinost commented 2 years ago

What I am trying to do

I am trying to use kotlinx.html with Spring WebFlux.

I am trying to call a suspending function inside a rendering block.

An action which would roughly look like this is currently not possible.

@GetMapping("/")
suspend fun index(): String {
    // Note: Using String + StringBuffer here only for simplicity
    return StringBuffer().let { sb
        sb.appendHTML().html {
            // Cannot call a suspending function here as `block` is `crossinline`
            // "Suspension functions can be called only within coroutine body" 
            someService.someSuspendingFunction()
        }  
        sb.toString()
     }
}

If crossinline was removed from html this would be possible.

Why I don't want to call all suspend functions before rendering HTML

Because I want to build components which use suspend functions.

A simple example would be a component which uses ResourceUrlProvider to get the URL to a resource.

ResourceUrlProvider#getForUriString returns Mono<String> which I would like to convert to a suspend function using awaitFirst.

It would be quite bothersome to do all those tasks inside the action first.

Would it be possible to remove crossinline?

I tested removing crossinline locally. I had to remove it from three places:

Then I included the project locally into my project using includeBuild and could successfully render HTML that way.

So the code would compile and I also think this would not be a breaking change as crossinline lambdas are a subset of all possible lambdas?

Thanks for your help!

fmcarvalho commented 1 year ago

I have the same need. Here you have another use case https://stackoverflow.com/q/73788797/1140754

I want to immediately start emitting static HTML (i.e. <html><body><h1>Artist Info</h1>), then suspend until data is available and then proceed.

suspend fun viewArtistInfo(fileName: String, cfArtist: CompletableFuture<Artist>) {
    FileWriter(fileName).use {
        it
            .appendHTML()
            .html {
                body {
                    h1 { +"Artist Info" }
                    val artist = cfArtist.await() // ERROR Suspension functions can be called only within coroutine body
                    p { +"From: ${artist.from}" }
                }
        }
    }
}
sgammon commented 1 year ago

@felixscheinost cc @fmcarvalho

thanks to Kotlin extension functions, this is actually possible without a lib update. here is how i solved this in my framework, elide:

first, add your own methods to HTML for body and head, which have suspend, and redirect their calls to visitSuspend:

package kotlinx.html.tagext;
// imports...

/**
 * Open a `<body>` tag with support for suspension calls.
 *
 * @Param classes Classes to apply to the body tag in the DOM.
 * @param block Callable block to configure and populate the body tag.
 */
@HtmlTagMarker
public suspend inline fun HTML.body(
  classes : String? = null,
  crossinline block : suspend BODY.() -> Unit
) : Unit = BODY(
  attributesMapOf("class", classes),
  consumer
).visitSuspend(block)

/**
 * Open a `<head>` tag with support for suspension calls.
 *
 * @param block Callable block to configure and populate the body tag.
 */
@HtmlTagMarker
public suspend inline fun HTML.head(
  crossinline block : suspend HEAD.() -> Unit
) : Unit = HEAD(emptyMap, consumer).visitSuspend(
  block
)

next, implement visitSuspend:

package kotlinx.html.tagext;
// imports...

// Visitor with suspension support.
public suspend inline fun <T : Tag> T.visitSuspend(crossinline block: suspend T.() -> Unit): Unit = visitTagSuspend {
  block()
}

// Tag visitor with suspension support.
@Suppress("TooGenericExceptionCaught")
public suspend inline fun <T : Tag> T.visitTagSuspend(crossinline block: suspend T.() -> Unit) {
  consumer.onTagStart(this)
  try {
    this.block()
  } catch (err: Throwable) {
    consumer.onTagError(this, err)
  } finally {
    consumer.onTagEnd(this)
  }
}

then, you can just import your extensions...

import kotlinx.html.tagext.body
import kotlinx.html.tagext.head
import kotlinx.html.title

and use them regularly where you need suspend support:

    @Get("/") suspend fun indexPage(request: HttpRequest<*>) = ssr(request) {
      head {
        title { +"Hello, Elide!" }
        stylesheet(asset("styles.base"))
        stylesheet("/styles/main.css")
        script("/scripts/ui.js", defer = true)
      }
      body {
        injectSSR(this@Index, request)
      }
    }