Skip to content
3 changes: 2 additions & 1 deletion html/eap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ with strict validation. Browser hydration follows the server's mode. Direct call
| `allowHydrationMismatch()` | Applies client values | Applies client values |
| Structure differs | Renders on the client | Renders on the client |

Strict mode also checks class tokens and foreign-attribute collisions.
Strict mode also checks tag and attribute names, class tokens,
foreign-attribute collisions and table-element nesting.

## Published artifacts

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
* calls stay intact, as do elements whose contents require validation. A chunk can exceed [chunkSize],
* and no empty chunks are sent.
*
* Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation.
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
*
* [sink] runs synchronously while rendering and may block. Output already sent cannot be retracted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,15 @@ package org.jetbrains.compose.web
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composer
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.snapshots.Snapshot
import org.jetbrains.compose.web.dom.AttrsBuilderPool
import org.jetbrains.compose.web.dom.HtmlStringWriterContext
import org.jetbrains.compose.web.dom.LocalComposeHtmlContext

/**
* Composes [content] once into an HTML string without creating browser DOM nodes.
*
* Snapshot state changes are discarded after rendering. Coroutine effects such as
* `LaunchedEffect` do not run. `SideEffect` and `DisposableEffect` still execute.
* Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation.
* Coroutine effects such as `LaunchedEffect` do not run. `SideEffect` and `DisposableEffect` still execute.
*
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
* See [composeHtmlToStream] for validation and failure handling.
Expand Down Expand Up @@ -63,7 +62,7 @@ internal fun composeHtmlString(
val pool = getHtmlRendererPool(key)
val renderer = pool?.borrow() ?: HtmlRenderer()

// Return storage to the pool only after rendering, effect cleanup and snapshot disposal succeed.
// Return storage to the pool only after rendering and effect cleanup succeed.
val result = renderer.render(hydratable, requireHtmlDocumentRoot, chunkSink, chunkSize, prefix, content)
pool?.recycle(renderer)
return result
Expand All @@ -90,39 +89,32 @@ internal class HtmlRenderer {
prefix: String,
content: @Composable () -> Unit,
): String {
val snapshot = Snapshot.takeMutableSnapshot()
try {
output.append(prefix)
val context = HtmlStringWriterContext(hydratable, attrsBuilders, output, chunkSink, chunkSize)
return snapshot.enter {
var failure: Throwable? = null
try {
val wrapped: @Composable () -> Unit = {
CompositionLocalProvider(LocalComposeHtmlContext provides context) { content() }
}
@Suppress("UNCHECKED_CAST")
(wrapped as (Composer, Int) -> Unit)(composer, 1)
composer.applyEffects()
context.finish(requireHtmlDocumentRoot).also {
if (isOversized(output.capacity(), context.peakBufferSize)) {
if (chunkSink == null) output.trimToSize()
else output = StringBuilder(context.peakBufferSize)
}
var failure: Throwable? = null
try {
val wrapped: @Composable () -> Unit = {
CompositionLocalProvider(LocalComposeHtmlContext provides context) { content() }
}
@Suppress("UNCHECKED_CAST")
(wrapped as (Composer, Int) -> Unit)(composer, 1)
composer.applyEffects()
return context.finish(requireHtmlDocumentRoot).also {
if (isOversized(output.capacity(), context.peakBufferSize)) {
if (chunkSink == null) output.trimToSize()
else output = StringBuilder(context.peakBufferSize)
}
} catch (cause: Throwable) {
failure = cause
throw cause
} finally {
composer.dispose(failure)
}
}
} finally {
try {
snapshot.dispose()
} catch (cause: Throwable) {
failure = cause
throw cause
} finally {
// Clear the output before reuse. The underlying buffer is not erased.
output.setLength(0)
composer.dispose(failure)
}
} finally {
// Clear the output before reuse. The underlying buffer is not erased.
output.setLength(0)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,11 @@ private const val HtmlDoctype = "<!doctype html>"
* Load client code that calls `hydrateRoot` after [HydrationRoot] and its state element are parsed:
* place the script after them, defer an external classic script, or use a module script.
*
* Snapshot state changes are discarded after rendering.
*
* On the JVM or Node.js, `COMPOSE_HTML_VALIDATE_STRICTLY=true` enables strict validation.
* [validateStrictly] overrides it.
* Without strict validation, browser hydration trusts initial server values.
*
* Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation.
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
* See [composeHtmlToStream] for validation and failure handling.
*
Expand All @@ -50,6 +49,7 @@ fun renderHydratedDocument(
/**
* Renders a complete document with fresh storage.
*
* Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation.
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
* See [composeHtmlToStream] for validation and failure handling.
*/
Expand All @@ -67,6 +67,7 @@ fun renderHydratedDocument(
* 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.
*
* Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation.
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
* See [composeHtmlToStream] for validation and failure handling.
*
Expand All @@ -89,6 +90,7 @@ fun renderHydratedDocumentToStream(
/**
* Streams a complete document with fresh storage.
*
* Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation.
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
* See [composeHtmlToStream] for validation and failure handling.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import org.jetbrains.compose.web.attributes.AttrsScope
import org.jetbrains.compose.web.attributes.AttrsScopeBuilder
import org.jetbrains.compose.web.attributes.ScriptType

internal const val HydrationRootAttribute = "data-compose-hydration-root"
internal const val HydrationStateAttribute = "data-compose-hydration-state"
internal const val HydrationForAttribute = "data-compose-hydration-for"
internal const val HydrationValidationAttribute = "data-compose-hydration-validation"
internal const val HydrationAttributePrefix = "data-compose-hydration-"
internal const val HydrationRootAttribute = "${HydrationAttributePrefix}root"
internal const val HydrationStateAttribute = "${HydrationAttributePrefix}state"
internal const val HydrationForAttribute = "${HydrationAttributePrefix}for"
internal const val HydrationValidationAttribute = "${HydrationAttributePrefix}validation"
internal const val HydrationValidationEnabled = "on"
internal const val HydrationStateFormat = "escaped-text-v1"
internal val HydrationStateMimeType: String = ScriptType.TextPlain.typeStr
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import androidx.compose.runtime.tooling.CompositionData
import androidx.compose.runtime.tooling.CompositionGroup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.jetbrains.compose.web.dom.ComposeHtmlContext
import org.jetbrains.compose.web.dom.LocalComposeHtmlContext
import org.jetbrains.compose.web.internal.unsafeCast

/**
Expand All @@ -33,6 +35,8 @@ internal class SinglePassComposer : Composer {
private var hashes = LongArray(64)
private var depth = 0
private var locals = emptyCompositionLocalMap
private var htmlContext: ComposeHtmlContext? = null
private var validationMode: HtmlValidationMode? = HtmlValidationMode.Fast
private val providers = ArrayList<PersistentCompositionLocalMap>()
private val observers = ArrayList<RememberObserver>()
private val effects = ArrayList<() -> Unit>()
Expand Down Expand Up @@ -70,6 +74,8 @@ internal class SinglePassComposer : Composer {
depth = 0
children[0] = 0
locals = emptyCompositionLocalMap
htmlContext = null
validationMode = HtmlValidationMode.Fast
providers.clear()
observers.clear()
effects.clear()
Expand Down Expand Up @@ -118,13 +124,42 @@ internal class SinglePassComposer : Composer {
override fun changed(value: Double) = true

override val currentCompositionLocalMap: CompositionLocalMap get() = locals
override fun <T> consume(key: CompositionLocal<T>): T = locals.read(key)
@Suppress("UNCHECKED_CAST")
override fun <T> consume(key: CompositionLocal<T>): T = when {
key === LocalComposeHtmlContext && htmlContext != null -> htmlContext as T
key === LocalHtmlValidationMode && validationMode != null -> validationMode as T
else -> locals.read(key)
}
private fun refreshHtmlLocals() {
// Computed providers must remain lazy and read from the current map on every consume.
val contextHolder = locals[LocalComposeHtmlContext.unsafeCast<CompositionLocal<Any?>>()]
htmlContext = if (contextHolder is StaticValueHolder<*>) locals.read(LocalComposeHtmlContext) else null
val validationHolder = locals[LocalHtmlValidationMode.unsafeCast<CompositionLocal<Any?>>()]
validationMode = when (validationHolder) {
null -> HtmlValidationMode.Fast
is StaticValueHolder<*> -> locals.read(LocalHtmlValidationMode)
else -> null
}
}
override fun startProviders(values: Array<out ProvidedValue<*>>) {
providers.add(locals)
locals = locals.builder().apply { putAll(updateCompositionMap(values, locals)) }.build()
val parent = locals
// Merge fresh providers into the parent to avoid a temporary map.
// Check this and static-value caching against the Compose runtime when upgrading.
val builder = parent.builder()
@Suppress("UNCHECKED_CAST")
for (provided in values) {
val local = provided.compositionLocal as ProvidableCompositionLocal<Any?>
if (provided.canOverride || !parent.contains(local)) {
builder[local] = local.updatedStateOf(provided as ProvidedValue<Any?>, null)
}
}
locals = builder.build()
refreshHtmlLocals()
}
override fun endProviders() {
locals = providers.removeAt(providers.lastIndex)
refreshHtmlLocals()
}
override fun startProvider(value: ProvidedValue<*>) = startProviders(arrayOf(value))
override fun endProvider() = endProviders()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,15 +374,18 @@ private val setClassList: (HTMLElement, Array<out String>) -> Unit = { e, classL

internal fun Collection<String>.toClassAttributeValue(validate: Boolean = true): String? {
if (isEmpty()) return null
if (validate) {
val seen = mutableSetOf<String>()
forEach { token ->
require(token.isNotEmpty()) { "Class token must not be empty" }
require(token.none { it in "\t\n\u000C\r " }) {
"Class token must not contain ASCII whitespace: \"$token\""
}
require(seen.add(token)) { "Class token must not be repeated: \"$token\"" }
if (validate) validateClassTokens()
return joinToString(" ")
}

internal fun Collection<String>.validateClassTokens() {
if (isEmpty()) return
val seen = mutableSetOf<String>()
forEach { token ->
require(token.isNotEmpty()) { "Class token must not be empty" }
require(token.none { it in "\t\n\u000C\r " }) {
"Class token must not contain ASCII whitespace: \"$token\""
}
require(seen.add(token)) { "Class token must not be repeated: \"$token\"" }
}
return joinToString(" ")
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface StyleScope {
* ```
*/
fun property(propertyName: String, value: StylePropertyValue)

/**
* Adds arbitrary CSS property to the inline style of the element. By default throws an error for backward compatibility
* @param propertyName - the name of css property as [per spec](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference)
Expand All @@ -62,13 +63,19 @@ interface StyleScope {
* })
* ```
*/
fun property(propertyName: String, value: StylePropertyValue, important: Boolean): Unit = error("!important is not supported by this implementation")
fun property(propertyName: String, value: StylePropertyValue, important: Boolean): Unit =
error("!important is not supported by this implementation")

fun variable(variableName: String, value: StylePropertyValue)

fun property(propertyName: String, value: String) = property(propertyName, StylePropertyValue(value))
fun property(propertyName: String, value: String, important: Boolean) = property(propertyName, StylePropertyValue(value), important)
fun property(propertyName: String, value: String, important: Boolean) =
property(propertyName, StylePropertyValue(value), important)

fun property(propertyName: String, value: Number) = property(propertyName, StylePropertyValue(value))
fun property(propertyName: String, value: Number, important: Boolean) = property(propertyName, StylePropertyValue(value), important)
fun property(propertyName: String, value: Number, important: Boolean) =
property(propertyName, StylePropertyValue(value), important)

fun variable(variableName: String, value: String) = variable(variableName, StylePropertyValue(value))
fun variable(variableName: String, value: Number) = variable(variableName, StylePropertyValue(value))

Expand Down Expand Up @@ -151,6 +158,7 @@ fun <TValue : StylePropertyValue> variable() =
interface StyleHolder {
@ComposeWebInternalApi
val properties: StylePropertyList

@ComposeWebInternalApi
val variables: StylePropertyList
}
Expand Down Expand Up @@ -183,7 +191,7 @@ open class StyleScopeBuilder : StyleScope, StyleHolder {
override fun equals(other: Any?): Boolean {
return if (other is StyleHolder) {
properties.nativeEquals(other.properties) &&
variables.nativeEquals(other.variables)
variables.nativeEquals(other.variables)
} else false
}

Expand Down Expand Up @@ -213,6 +221,10 @@ typealias MutableStylePropertyList = MutableList<StylePropertyDeclaration>
@OptIn(ComposeWebInternalApi::class)
internal fun StyleHolder.toStyleAttributeValue(): String? {
if (properties.isEmpty() && variables.isEmpty()) return null
val ordinary = StringBuilder()
if (ordinary.appendOrdinaryDeclarations(properties, "") &&
ordinary.appendOrdinaryDeclarations(variables, "--")
) return ordinary.toString()
val declarations = properties + variables.map { it.copy(name = "--${it.name}") }
val removedNames = mutableSetOf<String>()
val resetPriorityNames = mutableSetOf<String>()
Expand All @@ -225,6 +237,7 @@ internal fun StyleHolder.toStyleAttributeValue(): String? {
removedNames.add(declaration.name)
false
}

declaration.important -> declaration.name !in resetPriorityNames
else -> {
resetPriorityNames.add(declaration.name)
Expand All @@ -243,6 +256,18 @@ internal fun StyleHolder.toStyleAttributeValue(): String? {
}
}

// Convert each ordinary value once, including numeric CSS carriers.
private fun StringBuilder.appendOrdinaryDeclarations(declarations: StylePropertyList, prefix: String): Boolean {
for (declaration in declarations) {
if (declaration.important) return false
val value = declaration.value.toString()
if (value.isEmpty()) return false
if (isNotEmpty()) append("; ")
append(prefix).append(declaration.name).append(": ").append(value)
}
return true
}

internal fun StylePropertyList.nativeEquals(properties: StylePropertyList): Boolean {
if (this.size != properties.size) return false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import kotlinx.browser.dom.Element
import kotlinx.browser.dom.HTMLStyleElement
import org.jetbrains.compose.web.attributes.AttrsScope
import org.jetbrains.compose.web.css.CSSRuleDeclarationList
import org.jetbrains.compose.web.css.utils.serializeRules
import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi

internal interface ComposeHtmlContext {
Expand All @@ -34,6 +33,7 @@ internal interface ComposeHtmlContext {
fun <TElement : Element> TagElement(
elementBuilder: ElementBuilder<TElement>,
applyAttrs: (AttrsScope<TElement>.() -> Unit)?,
rawTextContent: RawTextContent?,
content: (@Composable ElementScope<TElement>.() -> Unit)?,
)

Expand Down Expand Up @@ -75,6 +75,7 @@ fun <TElement : Element> TagElement(
LocalComposeHtmlContext.current.TagElement(
elementBuilder = elementBuilder,
applyAttrs = applyAttrs,
rawTextContent = null,
content = content,
)
}
Expand All @@ -99,6 +100,7 @@ fun <TElement : Element> TagElementNS(
context.TagElement(
elementBuilder = context.elementBuilderNS(tagName, namespace),
applyAttrs = applyAttrs,
rawTextContent = null,
content = content,
)
}
Expand Down Expand Up @@ -128,6 +130,7 @@ fun <TElement : Element> TagElement(
context.TagElement(
elementBuilder = context.elementBuilder(tagName),
applyAttrs = applyAttrs,
rawTextContent = null,
content = content,
)
}
Expand Down Expand Up @@ -157,7 +160,12 @@ internal fun <TElement : Element> RawTextElement(
internal fun prepareStyleRawTextContent(cssRules: CSSRuleDeclarationList): RawTextContent =
RawTextContent.create(
tagName = "style",
content = cssRules.serializeRules().joinToString("\n"),
content = buildString {
cssRules.forEachIndexed { index, rule ->
if (index != 0) append('\n')
appendRulePresentation(rule, baseIndent = "", indent = " ", delimiter = "")
}
},
)

internal class RawTextContent private constructor(
Expand Down
Loading
Loading