Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/

package org.jetbrains.compose.web

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider

/**
* Composes [content] once and sends HTML chunks synchronously to [sink]. Concatenating the chunks
* produces the same HTML as [composeHtmlToString] with the same arguments.
*
* [chunkSize] defaults to 2048 and is a target minimum measured in UTF-16 code units. Tags and individual `Text`
* calls stay intact, as do elements whose contents require validation. Leading-newline repair
* for `pre`, `textarea`, and `listing` happens before their first content is emitted.
* A chunk can exceed [chunkSize], and no empty chunks are sent.
*
* With no [key], the composition is disposed after rendering. A key retains the composition and
* attribute-builder storage for later calls to either rendering function. Keyed calls are not
* thread-safe, even with different keys. Snapshot state changes are discarded; coroutine effects
* such as `LaunchedEffect` do not run, while `SideEffect` and `DisposableEffect` still execute.
*
* [sink] runs synchronously while rendering and may block. Output already sent cannot be retracted
* if rendering or the sink throws. A failed keyed render is disposed before the exception is rethrown.
*
* @param hydratable whether to emit text-boundary markers needed to hydrate adjacent `Text` calls.
* @param validateStrictly overrides the default strict-validation setting for this render.
* @throws IllegalArgumentException if [chunkSize] is not positive or content cannot be serialized.
* @throws IllegalStateException if another render with the same non-null [key] is in progress.
*/
fun composeHtmlToStream(
sink: (String) -> Unit,
chunkSize: Int = 2048,
hydratable: Boolean = true,
key: String? = null,
validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict,
content: @Composable () -> Unit,
) {
require(chunkSize > 0) { "chunkSize must be positive" }
val chunkSink: (StringBuilder) -> Unit = { sink(it.toString()) }
val validatedContent: @Composable () -> Unit = {
CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) {
content()
}
}
if (key == null) {
composeHtmlString(hydratable = hydratable, content = validatedContent, chunkSink = chunkSink, chunkSize = chunkSize)
} else {
composeReusableHtmlString(key, hydratable, content = validatedContent, chunkSink = chunkSink, chunkSize = chunkSize)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,13 @@ fun composeHtmlToString(
return if (key == null) {
composeHtmlString(hydratable = hydratable, content = validatedContent)
} else {
composeReusableHtmlString(key, hydratable, validatedContent)
composeReusableHtmlString(key, hydratable, content = validatedContent)
}
}

private const val LARGE_BUFFER_THRESHOLD = 64 * 1024
private const val BUFFER_SHRINK_FACTOR = 4

private val reusableHtmlCompositions = mutableMapOf<String, ReusableHtmlComposition>()

private class ReusableHtmlComposition {
Expand All @@ -78,16 +81,19 @@ private class ReusableHtmlComposition {
}
}

private fun composeReusableHtmlString(
/** Reuses a keyed composition and its attribute builders across string and streaming renders. */
internal fun composeReusableHtmlString(
key: String,
hydratable: Boolean,
chunkSink: ((StringBuilder) -> Unit)? = null,
chunkSize: Int = 2048,
content: @Composable () -> Unit,
): String {
val renderer = reusableHtmlCompositions.getOrPut(key, ::ReusableHtmlComposition)
check(!renderer.rendering) { "composeHtmlToString key \"$key\" is already rendering" }
check(!renderer.rendering) { "HTML render key \"$key\" is already rendering" }

renderer.output.setLength(0) // empty string output before next render
val context = HtmlStringWriterContext(hydratable, renderer.attrsBuilders, renderer.output)
renderer.output.setLength(0) // Discard output left by the previous render.
val context = HtmlStringWriterContext(hydratable, renderer.attrsBuilders, renderer.output, chunkSink, chunkSize)
val snapshot = Snapshot.takeMutableSnapshot()
renderer.rendering = true
renderer.content = content
Expand All @@ -99,7 +105,12 @@ private fun composeReusableHtmlString(
checkNotNull(renderer.content).invoke()
}
}
context.finish(requireHtmlDocumentRoot = false)
context.finish(requireHtmlDocumentRoot = false).also {
// Keep capacity for large renders. Trim after much smaller ones.
if (isOversized(renderer.output.capacity(), context.peakBufferSize)) {
renderer.output.trimToSize()
}
}
} finally {
renderer.composition.deactivate()
}
Expand All @@ -115,13 +126,21 @@ private fun composeReusableHtmlString(
}
}

/** Runs a fresh composition while the context writes markup directly to its output buffer. */
private fun isOversized(capacity: Int, used: Int): Boolean =
capacity > LARGE_BUFFER_THRESHOLD && used.toLong() * BUFFER_SHRINK_FACTOR < capacity

/** Runs a fresh composition with a buffer shared by string and streaming output. */
internal fun composeHtmlString(
hydratable: Boolean = true,
requireHtmlDocumentRoot: Boolean = false,
chunkSink: ((StringBuilder) -> Unit)? = null,
chunkSize: Int = 2048,
prefix: String = "",
content: @Composable () -> Unit,
): String {
val context = HtmlStringWriterContext(hydratable)
val context = HtmlStringWriterContext(
hydratable, output = StringBuilder(prefix), chunkSink = chunkSink, chunkSize = chunkSize,
)
val snapshot = Snapshot.takeMutableSnapshot()

return try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,51 @@ private const val HtmlDoctype = "<!doctype html>"
*
* That client code must run only after the [HydrationRoot] and its state element have been parsed.
* Place its script after [HydrationRoot], defer an external classic script, or use a module script.
* Use [renderHydratedDocumentToStream] to emit the document incrementally.
*/
fun renderHydratedDocument(
validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict,
content: @Composable () -> Unit,
): String = composeHydratedDocument(validateStrictly = validateStrictly, content = content)

/**
* Streams the same HTML as [renderHydratedDocument], including its doctype and hydration state.
* [content] must produce exactly one `html` element; client loading requirements follow
* [renderHydratedDocument].
*
* [chunkSize] defaults to 2048 UTF-16 code units. Tags and `Text` calls stay intact, so chunks
* may exceed the target. [sink] runs synchronously, may block, and receives no empty chunks.
* Emitted output cannot be retracted on failure, including document validation after composition.
*
* Each call disposes its composition. Effects, snapshots, and buffering follow [composeHtmlToStream].
*
* @param validateStrictly overrides validation and records the choice in hydration state.
* @throws IllegalArgumentException if [chunkSize] is not positive or [content] is not a valid document.
*/
fun renderHydratedDocumentToStream(
sink: (String) -> Unit,
chunkSize: Int = 2048,
validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict,
content: @Composable () -> Unit,
) {
require(chunkSize > 0) { "chunkSize must be positive" }
composeHydratedDocument(validateStrictly, chunkSink = { sink(it.toString()) }, chunkSize = chunkSize, content = content)
}

/** Shared document context and protocol for complete-string and streaming output. */
internal fun composeHydratedDocument(
validateStrictly: Boolean,
chunkSink: ((StringBuilder) -> Unit)? = null,
chunkSize: Int = 2048,
content: @Composable () -> Unit,
): String {
val seenHydrationIds = if (validateStrictly) mutableSetOf<String>() else null
return HtmlDoctype + composeHtmlString(requireHtmlDocumentRoot = true) {
return composeHtmlString(
requireHtmlDocumentRoot = true,
chunkSink = chunkSink,
chunkSize = chunkSize,
prefix = HtmlDoctype,
) {
CompositionLocalProvider(
LocalHydratedDocumentContext provides true,
LocalHydrationIds provides seenHydrationIds,
Expand All @@ -59,7 +97,7 @@ fun renderHydratedDocument(
* that do not need client-side reconciliation.
*
* DOM references and element effects used through the content receiver are available after
* browser hydration, but throw during server string rendering.
* browser hydration, but throw during server rendering.
*/
@Composable
fun <T> HydrationRoot(
Expand All @@ -70,7 +108,7 @@ fun <T> HydrationRoot(
content: @Composable ElementScope<HTMLDivElement>.(T) -> Unit,
) {
check(LocalHydratedDocumentContext.current) {
"HydrationRoot must be called inside renderHydratedDocument"
"HydrationRoot must be called inside renderHydratedDocument or renderHydratedDocumentToStream"
}
require(hydrationId == null || hydrationId.isNotBlank()) {
"hydrationId must not be blank"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,13 @@ internal class RawTextContent private constructor(
) {
fun validateAttributes(attributes: Map<String, String>) {
if (tagName == "script") {
require(attributes.keys.none { it.equals("src", ignoreCase = true) }) {
"Inline <script> content cannot be combined with a src attribute"
}
validateScriptSource(attributes.keys.any { it.equals("src", ignoreCase = true) })
}
}

fun validateScriptSource(hasSource: Boolean) {
require(tagName != "script" || !hasSource) {
"Inline <script> content cannot be combined with a src attribute"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
package org.jetbrains.compose.web.dom

import org.jetbrains.compose.web.HydrationProtocolAttributes
import org.jetbrains.compose.web.attributes.AttrsScopeBuilder
import org.jetbrains.compose.web.attributes.toClassAttributeValue
import org.jetbrains.compose.web.css.toStyleAttributeValue

// HTML parsing merges adjacent non-empty text nodes. This comment preserves their boundary.
internal const val HydrationTextBoundaryMarker = "c"
Expand All @@ -31,6 +34,9 @@ internal fun isHtmlVoidElement(tagName: String, namespace: String): Boolean =
// HTML parsing discards the first LF in these elements.
private val HtmlLeadingNewlineElementNames = setOf("pre", "textarea", "listing")

internal fun isHtmlLeadingNewlineElement(tagName: String, namespace: String): Boolean =
namespace == HtmlNamespace && tagName in HtmlLeadingNewlineElementNames

// Only empty boolean attribute values may be minimized without losing their value.
internal val HtmlBooleanAttributeNames = setOf(
"allowfullscreen",
Expand Down Expand Up @@ -83,27 +89,8 @@ internal class StringHtmlAttributes private constructor(
): StringHtmlAttributes {
if (attributes.isEmpty() && classAttributeValue == null && styleAttributeValue == null) return Empty

val serializedAttributes = linkedMapOf<String, String>()
attributes.forEach { (name, value) ->
requireValidHtmlAttributeName(name)
if (validate && namespace != HtmlNamespace) {
val duplicateName = serializedAttributes.keys.firstOrNull {
it.hasSameHtmlParserAttributeName(name)
}
require(duplicateName == null) {
"Duplicate HTML attribute names \"$duplicateName\" and \"$name\""
}
}
val protocolName = HydrationProtocolAttributes.firstOrNull {
it.equals(name, ignoreCase = true)
}
require(
protocolName == null || protocolName in hydrationProtocolAttributes
) {
"Attribute \"$name\" is owned by the Compose hydration protocol"
}
serializedAttributes[name] = value
}
validateStringHtmlAttributes(attributes, namespace, validate, hydrationProtocolAttributes)
val serializedAttributes = linkedMapOf<String, String>().apply { putAll(attributes) }

if ("class" !in serializedAttributes && classAttributeValue != null) {
serializedAttributes["class"] = classAttributeValue
Expand All @@ -122,28 +109,79 @@ internal class StringHtmlAttributes private constructor(
internal fun StringBuilder.appendStartTag(tagName: String, namespace: String, attributes: Map<String, String>) {
append('<').append(tagName)
attributes.forEach { (name, value) ->
append(' ').append(name)
if (namespace != HtmlNamespace || value.isNotEmpty() || '-' in tagName || !name.isHtmlBooleanAttributeName()) {
append("=\"")
appendEscapedAttribute(value)
append('"')
appendAttribute(tagName, namespace, name, value)
}
append('>')
}

/** Consumes a builder synchronously, preserving attribute order without making a map snapshot. */
internal fun StringBuilder.appendStartTag(
tagName: String,
namespace: String,
builder: AttrsScopeBuilder<*>,
validate: Boolean,
): Boolean {
// Class validation runs even when an explicit class attribute overrides the class list.
val classValue = builder.classes.toClassAttributeValue(validate)
val attributes = builder.collect()
validateStringHtmlAttributes(attributes, namespace, validate, builder.hydrationProtocolAttributes)
val hasScriptSource = tagName == "script" && attributes.keys.any { it.equals("src", ignoreCase = true) }
append('<').append(tagName)
attributes.forEach { (name, value) -> appendAttribute(tagName, namespace, name, value) }
if ("class" !in attributes && classValue != null) appendAttribute(tagName, namespace, "class", classValue)
if ("style" !in attributes) {
builder.styleScopeOrNull?.toStyleAttributeValue()?.let { value ->
appendAttribute(tagName, namespace, "style", value)
}
}
append('>')
// Only inline script validation needs attribute information after the builder is reset.
return hasScriptSource
}

/** Closes an element whose content was appended after [contentStart]. */
internal fun StringBuilder.appendEndTag(tagName: String, namespace: String, contentStart: Int) {
if (namespace == HtmlNamespace) {
if (tagName == "noscript") {
// Render fallback HTML for scripting-disabled browsers, but keep it inside noscript
// when scripting is enabled and the parser treats the entire contents as raw text.
require(substring(contentStart).rawTextTagIndex("noscript", closing = true) < 0) {
"String-rendered <noscript> content must not contain a </noscript end tag"
private fun validateStringHtmlAttributes(
attributes: Map<String, String>,
namespace: String,
validate: Boolean,
hydrationProtocolAttributes: Set<String>,
) {
for (name in attributes.keys) {
requireValidHtmlAttributeName(name)
if (validate && namespace != HtmlNamespace) {
for (previous in attributes.keys) {
if (previous == name) break
require(!previous.hasSameHtmlParserAttributeName(name)) {
"Duplicate HTML attribute names \"$previous\" and \"$name\""
}
}
}
if (tagName in HtmlLeadingNewlineElementNames && length > contentStart && this[contentStart] == '\n') {
insert(contentStart, '\n')
val protocolName = HydrationProtocolAttributes.firstOrNull { it.equals(name, ignoreCase = true) }
require(protocolName == null || protocolName in hydrationProtocolAttributes) {
"Attribute \"$name\" is owned by the Compose hydration protocol"
}
}
}

private fun StringBuilder.appendAttribute(tagName: String, namespace: String, name: String, value: String) {
append(' ').append(name)
if (namespace != HtmlNamespace || value.isNotEmpty() || '-' in tagName || !name.isHtmlBooleanAttributeName()) {
append("=\"")
appendEscapedAttribute(value)
append('"')
}
}

/** Closes an element whose content was appended after [contentStart]. */
internal fun StringBuilder.appendEndTag(
tagName: String,
namespace: String,
contentStart: Int,
) {
if (namespace == HtmlNamespace && tagName == "noscript") {
// Render fallback HTML for scripting-disabled browsers, but keep it inside noscript
// when scripting is enabled and the parser treats the entire contents as raw text.
require(substring(contentStart).rawTextTagIndex("noscript", closing = true) < 0) {
"String-rendered <noscript> content must not contain a </noscript end tag"
}
}
append("</").append(tagName).append('>')
Expand Down
Loading
Loading