diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt
index 41fd0bfa7e8..177b4bb3090 100644
--- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt
+++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt
@@ -23,6 +23,7 @@ private const val HtmlDoctype = ""
* Renders a complete HTML document with optional browser hydration.
*
* [content] must produce exactly one `html` element. Output starts with an HTML doctype.
+ * Output starts with an HTML doctype.
* Static documents emit no hydration state.
*
* Load client code that calls `hydrateRoot` after [HydrationRoot] and its state element are parsed:
@@ -113,7 +114,7 @@ internal fun composeHydratedDocument(
): String {
val seenHydrationIds = if (validateStrictly) mutableSetOf() else null
return composeHtmlString(
- requireHtmlDocumentRoot = true,
+ requireHtmlDocumentRoot = validateStrictly,
chunkSink = chunkSink,
chunkSize = chunkSize,
prefix = HtmlDoctype,
diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/css/StyleScope.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/css/StyleScope.kt
index 497b0ced74f..497a03ba9fb 100644
--- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/css/StyleScope.kt
+++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/css/StyleScope.kt
@@ -218,49 +218,56 @@ data class StylePropertyDeclaration(
typealias StylePropertyList = List
typealias MutableStylePropertyList = MutableList
+// TODO: Investigate an append overload that avoids intermediate strings.
@OptIn(ComposeWebInternalApi::class)
internal fun StyleHolder.toStyleAttributeValue(): String? {
if (properties.isEmpty() && variables.isEmpty()) return null
+ // Reuse conversions if priority or removal needs a second pass.
+ // Skip removed values as before.
+ val values = arrayOfNulls(properties.size + variables.size)
val ordinary = StringBuilder()
- if (ordinary.appendOrdinaryDeclarations(properties, "") &&
- ordinary.appendOrdinaryDeclarations(variables, "--")
+ if (ordinary.appendOrdinaryDeclarations(properties, "", values, 0) &&
+ ordinary.appendOrdinaryDeclarations(variables, "--", values, properties.size)
) return ordinary.toString()
val declarations = properties + variables.map { it.copy(name = "--${it.name}") }
val removedNames = mutableSetOf()
val resetPriorityNames = mutableSetOf()
- // Walk backwards to retain fallbacks without repeatedly scanning earlier declarations.
- // Empty assignments remove a property. Normal assignments reset its earlier priority.
- return declarations.asReversed().filter { declaration ->
+ val included = BooleanArray(declarations.size)
+ for (index in declarations.lastIndex downTo 0) {
+ val declaration = declarations[index]
+ if (declaration.name in removedNames) continue
+ val value = values[index] ?: declaration.value.toString().also { values[index] = it }
when {
- declaration.name in removedNames -> false
- declaration.value.toString().isEmpty() -> {
- removedNames.add(declaration.name)
- false
- }
-
- declaration.important -> declaration.name !in resetPriorityNames
+ value.isEmpty() -> removedNames.add(declaration.name)
+ declaration.important -> included[index] = declaration.name !in resetPriorityNames
else -> {
resetPriorityNames.add(declaration.name)
- true
+ included[index] = true
}
}
- }.asReversed()
- .takeIf { declarations -> declarations.isNotEmpty() }
- ?.joinToString("; ") { declaration ->
- buildString {
- append(declaration.name)
- append(": ")
- append(declaration.value)
- if (declaration.important) append(" !important")
- }
+ }
+ return buildString {
+ for (index in declarations.indices) {
+ if (!included[index]) continue
+ val declaration = declarations[index]
+ if (isNotEmpty()) append("; ")
+ append(declaration.name).append(": ").append(values[index])
+ if (declaration.important) append(" !important")
}
+ }.takeIf { it.isNotEmpty() }
}
-// Convert each ordinary value once, including numeric CSS carriers.
-private fun StringBuilder.appendOrdinaryDeclarations(declarations: StylePropertyList, prefix: String): Boolean {
- for (declaration in declarations) {
+private fun StringBuilder.appendOrdinaryDeclarations(
+ declarations: StylePropertyList,
+ prefix: String,
+ values: Array,
+ offset: Int,
+): Boolean {
+ for (index in declarations.indices) {
+ val declaration = declarations[index]
if (declaration.important) return false
val value = declaration.value.toString()
+ values[offset + index] = value
if (value.isEmpty()) return false
if (isNotEmpty()) append("; ")
append(prefix).append(declaration.name).append(": ").append(value)
diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt
index 8c70bb412ed..555e51ee479 100644
--- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt
+++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt
@@ -226,7 +226,7 @@ internal fun String.rawTextTagIndex(
}
private fun requireValidRawTextContent(tagName: String, content: String) {
- require(tagName in listOf("script", "style", "iframe", "xmp", "noembed", "noframes")) {
+ require(isHtmlRawTextElement(tagName, HtmlNamespace)) {
"Raw text content is not supported for <$tagName>"
}
require(content.rawTextTagIndex(tagName, closing = true) < 0) {
diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Elements.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Elements.kt
index e855ab4b98b..c882fe41eb5 100644
--- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Elements.kt
+++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Elements.kt
@@ -6,6 +6,7 @@
package org.jetbrains.compose.web.dom
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
@@ -184,6 +185,7 @@ private val TextareaBuilder = ElementBuilder.createBuilder(
* safely embedded in a `style` element.
*/
@Composable
+@NonRestartableComposable
fun Style(
applyAttrs: (AttrsScope.() -> Unit)? = null,
cssRules: CSSRuleDeclarationList,
@@ -203,6 +205,7 @@ fun Style(
* safely embedded in a `style` element.
*/
@Composable
+@NonRestartableComposable
inline fun Style(
noinline applyAttrs: (AttrsScope.() -> Unit)? = null,
rulesBuild: StyleSheetBuilder.() -> Unit,
@@ -213,40 +216,54 @@ inline fun Style(
}
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Html(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(HtmlBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Head(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(HeadBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Body(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(BodyBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Title(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(TitleBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Meta(
attrs: AttrBuilderContext? = null,
) = TagElement(MetaBuilder, attrs, content = null)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Link(
attrs: AttrBuilderContext? = null,
) = TagElement(LinkBuilder, attrs, content = null)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Script(
attrs: AttrBuilderContext? = null,
) = TagElement(ScriptBuilder, attrs, content = null)
@@ -274,264 +291,351 @@ value class InlineScript(val content: String)
* @throws IllegalArgumentException if [content] is not safe HTML script raw text.
*/
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Script(
content: InlineScript,
attrs: AttrBuilderContext? = null,
) = RawTextElement("script", attrs, content.content)
@Composable
+@NonRestartableComposable
fun Div(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(DivBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Span(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(SpanBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Address(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(AddressBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Article(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(ArticleBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Aside(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(AsideBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Header(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(HeaderBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Section(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(SectionBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Nav(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(NavBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Main(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(MainBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Footer(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(FooterBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun H1(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(H1Builder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun H2(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(H2Builder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun H3(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(H3Builder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun H4(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(H4Builder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun H5(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(H5Builder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun H6(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(H6Builder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun P(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(PBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Em(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(EmBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun I(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(IBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun B(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(BBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Small(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(SmallBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Sup(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(SupBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Sub(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(SubBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Blockquote(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(BlockquoteBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Pre(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(PreBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Code(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(CodeBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Ul(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(UlBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Ol(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(OlBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Li(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(LiBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun DList(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(DlBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun DTerm(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(DtBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun DDescription(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(DdBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Audio(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(AudioBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Video(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(VideoBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Picture(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(PictureBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Canvas(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(CanvasBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun HTMLMap(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(MapBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Datalist(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(DatalistBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Fieldset(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(FieldsetBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Legend(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(LegendBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Meter(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(MeterBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Output(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(OutputBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Progress(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
@@ -543,72 +647,96 @@ fun Progress(
* attributes to supply the embedded document.
*/
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Iframe(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(IframeBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Object(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(ObjectBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Table(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(TableBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Caption(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(CaptionBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Colgroup(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(ColgroupBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Tr(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(TrBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Thead(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(TheadBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Th(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(ThBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Td(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(TdBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Tbody(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(TbodyBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Tfoot(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(TfootBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Button(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
@@ -650,6 +778,7 @@ fun Button(
*/
@OptIn(ComposeWebInternalApi::class)
@Composable
+@NonRestartableComposable
fun Input(
type: InputType,
attrs: InputAttrsScope.() -> Unit,
@@ -686,6 +815,7 @@ fun Input(
}
@Composable
+@NonRestartableComposable
fun Input(type: InputType) {
Input(type) {}
}
@@ -710,6 +840,7 @@ fun Input(type: InputType) {
*
*/
@Composable
+@NonRestartableComposable
fun TextArea(
value: String? = null,
attrs: (TextAreaAttrsScope.() -> Unit)? = null,
@@ -750,51 +881,69 @@ fun TextArea(
}
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Area(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(AreaBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Track(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(TrackBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Embed(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(EmbedBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Param(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(ParamBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Source(
attrs: AttrBuilderContext? = null,
content: ContentBuilder? = null,
) = TagElement(SourceBuilder, attrs, content)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Br(
attrs: AttrBuilderContext? = null,
) = TagElement(BrBuilder, attrs, content = null)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Hr(
attrs: AttrBuilderContext? = null,
) = TagElement(HrBuilder, attrs, content = null)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Col(
attrs: AttrBuilderContext? = null,
) = TagElement(ColBuilder, attrs, content = null)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun A(
href: String? = null,
attrs: AttrBuilderContext? = null,
@@ -813,6 +962,8 @@ fun A(
)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Img(
src: String,
alt: String = "",
@@ -829,6 +980,8 @@ fun Img(
)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Form(
action: String? = null,
attrs: AttrBuilderContext? = null,
@@ -847,6 +1000,8 @@ fun Form(
)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Select(
attrs: (SelectAttrsScope.() -> Unit)? = null,
multiple: Boolean = false,
@@ -865,6 +1020,8 @@ fun Select(
)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Option(
value: String,
attrs: AttrBuilderContext? = null,
@@ -881,6 +1038,8 @@ fun Option(
)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun OptGroup(
label: String,
attrs: AttrBuilderContext? = null,
@@ -897,6 +1056,8 @@ fun OptGroup(
)
@Composable
+@NonRestartableComposable
+@androidx.compose.runtime.ExplicitGroupsComposable
fun Label(
forId: String? = null,
attrs: AttrBuilderContext? = null,
@@ -915,6 +1076,7 @@ fun Label(
)
@Composable
+@NonRestartableComposable
fun Text(value: String) {
LocalComposeHtmlContext.current.TextElement(value)
}
diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.kt
index 4ff582abd2e..da8bc6bebbe 100644
--- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.kt
+++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.kt
@@ -255,9 +255,13 @@ internal fun StringBuilder.appendEscapedText(value: String) {
appendEscaped(value, attribute = false)
}
+// Benchmark-based heuristic: keep short strings on the simpler escaping path.
+internal const val HtmlEscapeTableMinLength = 256
+
// Append ordinary runs in bulk. Besides avoiding an append per character, this lets the
// JVM copy Latin-1 and UTF-16 runs with its native StringBuilder implementation.
private fun StringBuilder.appendEscaped(value: String, attribute: Boolean) {
+ if (value.length >= HtmlEscapeTableMinLength && appendEscapedHtmlString(value, attribute)) return
if (canAppendUnescapedHtmlString(value, attribute)) {
append(value)
return
@@ -287,6 +291,9 @@ private fun StringBuilder.appendEscaped(value: String, attribute: Boolean) {
if (start == 0) append(value) else appendHtmlRun(value, start, value.length)
}
+/** Handles long strings on platforms with a specialized serializer. */
+internal expect fun StringBuilder.appendEscapedHtmlString(value: String, attribute: Boolean): Boolean
+
/** A native search fast path where the platform supports it. */
internal expect fun canAppendUnescapedHtmlString(value: String, attribute: Boolean): Boolean
diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringWriterContext.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringWriterContext.kt
index 5fee3593774..759cd61b621 100644
--- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringWriterContext.kt
+++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringWriterContext.kt
@@ -94,9 +94,8 @@ internal class HtmlStringWriterContext(
val tagName = elementBuilder.tagName
val metadata = elementBuilder as? HtmlTagMetadata
val namespace = metadata?.namespace ?: HtmlNamespace
- val flags = metadata?.serializationFlags
- val rawTextElement = if (flags == null) isHtmlRawTextElement(tagName, namespace)
- else flags and RawTextTagFlag != 0
+ val flags = metadata?.serializationFlags ?: htmlStringTagFlags(tagName, namespace)
+ val rawTextElement = flags and RawTextTagFlag != 0
// Emit any completed chunk before writing this start tag.
flushChunk()
@@ -104,30 +103,31 @@ internal class HtmlStringWriterContext(
(rawTextElement || (namespace == HtmlNamespace && tagName == "noscript"))
if (bufferElement) bufferedElementDepth++
+ val validate = LocalHtmlValidationMode.current == HtmlValidationMode.Strict
val startTag = writeStartTag(
tagName = tagName,
namespace = namespace,
applyAttrs = applyAttrs,
- validate = LocalHtmlValidationMode.current == HtmlValidationMode.Strict,
- tagNameIsValid = flags != null && flags and ValidTagFlag != 0,
+ validate = validate,
+ tagNameIsValid = flags and ValidTagFlag != 0,
)
val contentOverride = startTag.textContent
// A raw-text parent rejects child tags, and a void parent returned above.
// Only the parent's tag and namespace need saving.
val parentTagName = currentTagName
val parentNamespace = currentNamespace
- val isVoid = if (flags == null) isHtmlVoidElement(tagName, namespace) else flags and VoidTagFlag != 0
- currentTagName = tagName
- currentNamespace = namespace
+ val isVoid = flags and VoidTagFlag != 0
+ // Skip unchanged reference stores on the ordinary HTML path.
+ if (currentTagName !== tagName) currentTagName = tagName
+ if (currentNamespace !== namespace) currentNamespace = namespace
previousSiblingWasText = false
- pendingLeadingNewline = if (flags == null) isHtmlLeadingNewlineElement(tagName, namespace)
- else flags and LeadingNewlineTagFlag != 0
+ pendingLeadingNewline = flags and LeadingNewlineTagFlag != 0
collectingRawText = rawTextElement
flushChunk()
// For buffered elements this index stays valid until appendEndTag inspects the content.
val contentStart = output.length
- if (!contentOverride.isNullOrEmpty()) writeText(contentOverride)
+ if (!contentOverride.isNullOrEmpty()) writeText(contentOverride, validate)
discardingContent = isVoid || contentOverride != null
if (rawTextContent != null && !discardingContent) {
rawTextContent.validateScriptSource(startTag.hasScriptSource)
@@ -136,8 +136,8 @@ internal class HtmlStringWriterContext(
content?.invoke(elementScope())
if (!isVoid) writeEndTag(tagName, namespace, startTag.hasScriptSource, contentStart)
- currentTagName = parentTagName
- currentNamespace = parentNamespace
+ if (currentTagName !== parentTagName) currentTagName = parentTagName
+ if (currentNamespace !== parentNamespace) currentNamespace = parentNamespace
previousSiblingWasText = false
pendingLeadingNewline = false // This element's start tag consumed its parent's first content.
discardingContent = false
@@ -171,7 +171,7 @@ internal class HtmlStringWriterContext(
}
value.isNotEmpty() -> {
- writeText(value)
+ writeText(value, LocalHtmlValidationMode.current == HtmlValidationMode.Strict)
flushChunk()
}
}
@@ -259,9 +259,9 @@ internal class HtmlStringWriterContext(
output.appendEndTag(tagName, namespace, contentStart)
}
- private fun writeText(value: String) {
+ private fun writeText(value: String, validate: Boolean) {
if (currentTagName == null) recordRootChild(isHtml = false)
- if (currentNamespace == HtmlNamespace) requireHtmlParserStableTableText(currentTagName, value)
+ if (validate && currentNamespace == HtmlNamespace) requireHtmlParserStableTableText(currentTagName, value)
if (pendingLeadingNewline) {
if (value.first() == '\n') output.append('\n')
pendingLeadingNewline = false
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentPoolTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentPoolTest.kt
index fd9f916876d..dd442acc8be 100644
--- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentPoolTest.kt
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentPoolTest.kt
@@ -120,7 +120,7 @@ class HydratedDocumentPoolTest {
Html { Body { HydrationRoot("inner", { it }, hydrationId = "app") { Text(it) } } }
}
assertFailsWith {
- renderHydratedDocument(key = key) { Div() }
+ renderHydratedDocument(key = key, validateStrictly = true) { Div() }
}
Text(it)
}
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentStreamTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentStreamTest.kt
index 2246250ccf0..a533f4873a5 100644
--- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentStreamTest.kt
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentStreamTest.kt
@@ -145,7 +145,7 @@ class HydratedDocumentStreamTest {
}
@Test
- fun validatesDocumentShapeInBothModesIncludingWhenOutputHasAlreadyBeenSent() {
+ fun validatesDocumentShapeOnlyInStrictModeIncludingWhenOutputHasAlreadyBeenSent() {
val invalidDocuments = listOf<@Composable () -> Unit>(
{},
{ Text("fragment") },
@@ -157,6 +157,11 @@ class HydratedDocumentStreamTest {
for (size in listOf(1, Int.MAX_VALUE)) {
for (content in invalidDocuments) {
val chunks = mutableListOf()
+ if (!strict) {
+ renderHydratedDocumentToStream(chunks::add, size, strict, content)
+ assertEquals(renderHydratedDocument(validateStrictly = false, content = content), chunks.joinToString(""))
+ continue
+ }
val failure = assertFailsWith {
renderHydratedDocumentToStream(chunks::add, size, strict, content)
}
@@ -167,7 +172,7 @@ class HydratedDocumentStreamTest {
}
val partial = mutableListOf()
assertFailsWith {
- renderHydratedDocumentToStream(partial::add, chunkSize = 1) { Div() }
+ renderHydratedDocumentToStream(partial::add, chunkSize = 1, validateStrictly = true) { Div() }
}
assertEquals("", partial.joinToString(""))
}
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentTest.kt
index 72b2cd33791..ba7847b44ab 100644
--- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentTest.kt
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentTest.kt
@@ -228,14 +228,14 @@ class HydratedDocumentTest {
assertFalse("data-compose-hydration-state" in rendered)
}
- // Renders invalid top-level shapes to verify that only one html element is accepted.
+ // Strict mode rejects invalid document roots; fast mode omits the shape check.
@Test
- fun rejectsFragmentsAndMultipleHtmlElements() {
+ fun validatesDocumentShapeOnlyInStrictMode() {
val fragmentFailure = assertFailsWith {
- renderHydratedDocument { Div() }
+ renderHydratedDocument(validateStrictly = true) { Div() }
}
val multipleDocumentsFailure = assertFailsWith {
- renderHydratedDocument {
+ renderHydratedDocument(validateStrictly = true) {
Html()
Html()
}
@@ -243,6 +243,11 @@ class HydratedDocumentTest {
assertContains(fragmentFailure.message.orEmpty(), "exactly one html element")
assertContains(multipleDocumentsFailure.message.orEmpty(), "exactly one html element")
+ assertEquals("", renderHydratedDocument(validateStrictly = false) { Div() })
+ assertEquals("", renderHydratedDocument(validateStrictly = false) {
+ Html()
+ Html()
+ })
}
// Calls HydrationRoot through the ordinary renderer to verify the context error is clear.
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/FractionalStyleSsrTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/FractionalStyleSsrTest.kt
new file mode 100644
index 00000000000..c689758e7f5
--- /dev/null
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/FractionalStyleSsrTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 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.dom
+
+import org.jetbrains.compose.web.composeHtmlToString
+import org.jetbrains.compose.web.css.*
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class FractionalStyleSsrTest {
+ @Test
+ fun changingFractionalValuesRenderThroughInlineCss() {
+ for ((value, number, length) in listOf(
+ Triple(1f / 3f, "0.33333334", "0.33333334px"),
+ Triple(1.375f, "1.375", "1.375px"),
+ Triple(-2070951.25f, "-2070951.3", "-2070951.3px"),
+ )) {
+ assertEquals("",
+ composeHtmlToString(key = "fractional-regression") {
+ Div({
+ style {
+ width(value.px)
+ property("opacity", value)
+ variable("fraction", value)
+ }
+ }) {}
+ })
+ assertEquals("",
+ composeHtmlToString(key = "fractional-priority-regression") {
+ Div({ style {
+ property("opacity", value, true)
+ width(value.px)
+ property("removed", "unused")
+ property("removed", "")
+ variable("fraction", value)
+ } }) {}
+ })
+ }
+ }
+}
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlOrdinaryElementPathTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlOrdinaryElementPathTest.kt
new file mode 100644
index 00000000000..27f1e404c38
--- /dev/null
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlOrdinaryElementPathTest.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 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.dom
+
+import org.jetbrains.compose.web.composeHtmlToString
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class HtmlOrdinaryElementPathTest {
+ @Test
+ fun repeatedStringsUseTheCorrectEscapingContextAndChangingValuesStayFresh() {
+ val key = "ordinary-changing-escape-context"
+ val initial = "abcdefgh&\"\n\t"
+ repeat(3) {
+ assertEquals("abcdefgh&\"\n\t
",
+ composeHtmlToString(key = key) {
+ Div(attrs = { title(initial) }) { Text(initial) }
+ })
+ }
+ assertEquals("changed&<
",
+ composeHtmlToString(key = key) {
+ Div(attrs = { title("changed&<") }) { Text("changed&<") }
+ })
+ assertFailsWith {
+ composeHtmlToString(key = key) { Text("abcdefgh\u0000") }
+ }
+ }
+
+ @Test
+ fun changingAttributePositionsDoesNotLeakValuesAcrossPooledRenders() {
+ val key = "ordinary-changing-attribute-positions"
+ assertEquals("
",
+ composeHtmlToString(key = key) {
+ Div(attrs = { id("outer") }) {
+ Span()
+ Span(attrs = { title("last") })
+ }
+ })
+ assertEquals("
",
+ composeHtmlToString(key = key) {
+ Div {
+ Span(attrs = { classes("next") }) { Span() }
+ }
+ })
+ assertEquals("", composeHtmlToString(key = key) {
+ Div(attrs = { title("fresh") })
+ })
+ }
+
+ @Test
+ fun failureBetweenAttributeFreeElementsDoesNotContaminateTheNextRequest() {
+ val key = "ordinary-recovery-after-invalid-text"
+ assertFailsWith {
+ composeHtmlToString(key = key) {
+ Div { Span { Text("bad\u0000text") } }
+ }
+ }
+ assertEquals("é😀<
",
+ composeHtmlToString(key = key) {
+ Div { Span(attrs = { title("é😀&") }) { Text("é😀<") } }
+ })
+ }
+}
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlSerializationTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlSerializationTest.kt
index 6fe804ce52f..5dfa0a90e57 100644
--- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlSerializationTest.kt
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlSerializationTest.kt
@@ -107,12 +107,15 @@ class HtmlSerializationTest {
listOf("table", "tbody", "thead", "tfoot", "tr", "colgroup").forEach { parent ->
listOf("x", " x ", "\u00A0", "\u000B").forEach { text ->
val failure = assertFailsWith {
- composeHtmlToString(hydratable) {
+ composeHtmlToString(hydratable, validateStrictly = true) {
TagElement(parent, null) { Text(text) }
}
}
assertContains(failure.message.orEmpty(), "Non-whitespace text")
assertContains(failure.message.orEmpty(), "inside <$parent>")
+ assertEquals("<$parent>$text$parent>", composeHtmlToString(hydratable, validateStrictly = false) {
+ TagElement(parent, null) { Text(text) }
+ })
}
assertEquals("<$parent>\t\n\u000C $parent>", composeHtmlToString(hydratable) {
TagElement(parent, null) { Text("\t\n\u000C ") }
diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/css/CSSNumberFormatting.jvm.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/css/CSSNumberFormatting.jvm.kt
index 8c418dc6af3..697d83dca66 100644
--- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/css/CSSNumberFormatting.jvm.kt
+++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/css/CSSNumberFormatting.jvm.kt
@@ -9,7 +9,111 @@ import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
+// Use exact rounding near ties and on older JDKs.
+private val hasShortestFloatFormatting =
+ (System.getProperty("java.specification.version").substringAfterLast('.').toIntOrNull() ?: 0) >=
+ 19
+
+private val decimalPowers = DoubleArray(121) { java.lang.Double.parseDouble("1e${it - 60}") }
+
internal actual fun formatBinary32(value: Double): String {
+ val float = value.toFloat()
+ if (!float.isFinite()) return float.toString()
+ if (float == 0f) return "0"
+ if (!hasShortestFloatFormatting) return formatBinary32Exact(value)
+ val magnitude = kotlin.math.abs(float.toDouble())
+ var exponent = kotlin.math.floor(kotlin.math.log10(magnitude)).toInt()
+ while (magnitude < decimalPowers[exponent + 60]) exponent--
+ while (magnitude >= decimalPowers[exponent + 61]) exponent++
+ // Start from JDK precision, keeping CSS rounding and spelling.
+ val shortest = java.lang.Float.toString(float)
+ var digitsSeen = 0
+ var significantDigits = 0
+ var canonicalDigits = 0L
+ var started = false
+ for (char in shortest) {
+ if (char == 'E') break
+ if (char !in '0'..'9') continue
+ if (char != '0') started = true
+ if (started) {
+ digitsSeen++
+ canonicalDigits = canonicalDigits * 10 + (char - '0')
+ if (char != '0') significantDigits = digitsSeen
+ }
+ }
+ while (canonicalDigits % 10L == 0L) canonicalDigits /= 10
+ // Tiny values may need one digit instead of the JDK's two.
+ val firstPrecision = if (significantDigits <= 2) 1 else significantDigits
+ for (precision in firstPrecision..9) {
+ val scale = exponent - precision + 1
+ val scaled = magnitude / decimalPowers[scale + 60]
+ val floor = kotlin.math.floor(scaled)
+ // Allow four ulps for scaling error near a tie.
+ if (kotlin.math.abs(scaled - floor - 0.5) <= 4 * java.lang.Math.ulp(scaled)) {
+ return formatBinary32Exact(value)
+ }
+ val digits = (floor + if (scaled - floor >= 0.5) 1 else 0).toLong()
+ var normalized = digits
+ var normalizedScale = scale
+ while (normalized % 10L == 0L) {
+ normalized /= 10
+ normalizedScale++
+ }
+ if (normalized == canonicalDigits && normalizedScale == exponent - significantDigits + 1) {
+ // This decimal already round-trips. Only CSS notation may differ.
+ if ('E' !in shortest && !shortest.endsWith(".0")) return shortest
+ return decimalCssString(normalized, normalizedScale, float < 0f)
+ }
+ val candidate = decimalCssString(digits, scale, float < 0f)
+ // Parse as Float to avoid rounding twice.
+ if (java.lang.Float.parseFloat(candidate) == float) return candidate
+ }
+ return formatBinary32Exact(value)
+}
+
+private fun decimalCssString(unscaled: Long, scale: Int, negative: Boolean): String {
+ var digits = unscaled
+ var power = scale
+ while (digits % 10L == 0L) {
+ digits /= 10
+ power++
+ }
+ val text = digits.toString()
+ val exponent = text.length + power - 1
+ return buildString {
+ if (negative) append('-')
+ if (exponent in -6..20) {
+ val point = text.length + power
+ when {
+ point <= 0 -> {
+ append("0.")
+ repeat(-point) { append('0') }
+ append(text)
+ }
+ point >= text.length -> {
+ append(text)
+ repeat(power) { append('0') }
+ }
+ else -> {
+ append(text, 0, point)
+ append('.')
+ append(text, point, text.length)
+ }
+ }
+ } else {
+ append(text[0])
+ if (text.length > 1) {
+ append('.')
+ append(text, 1, text.length)
+ }
+ append('e')
+ if (exponent >= 0) append('+')
+ append(exponent)
+ }
+ }
+}
+
+private fun formatBinary32Exact(value: Double): String {
val float = value.toFloat()
// NaN, infinities and values beyond Float range lack CSS syntax but share a spelling.
if (!float.isFinite()) return float.toString()
diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.jvm.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.jvm.kt
index 53916a28409..17e34dd044b 100644
--- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.jvm.kt
+++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.jvm.kt
@@ -22,3 +22,61 @@ internal actual fun StringBuilder.appendHtmlRun(value: String, start: Int, end:
// JDK 21 bulk-copies String ranges; avoid constructing a temporary substring.
append(value, start, end)
}
+
+
+private object HtmlEscapeTable {
+ val flags = ByteArray(Char.MAX_VALUE.code + 1).apply {
+ for (character in "\u0000&<>\r") this[character.code] = 3
+ for (character in "\"\n\t") this[character.code] = 2
+ }
+}
+
+internal actual fun StringBuilder.appendEscapedHtmlString(value: String, attribute: Boolean): Boolean {
+ if (value.length < HtmlEscapeTableMinLength) return false
+ if (canAppendUnescapedHtmlString(value, attribute)) {
+ append(value)
+ return true
+ }
+ val flags = HtmlEscapeTable.flags
+ val mask = if (attribute) 2 else 1
+ var start = 0
+ while (true) {
+ var index = start
+ while (index < value.length && flags[value[index].code].toInt() and mask == 0) index++
+ if (index == value.length) {
+ appendHtmlRun(value, start, value.length)
+ return true
+ }
+ val character = value[index]
+ val replacement = when (character) {
+ '\u0000' -> throw IllegalArgumentException(
+ if (attribute) "HTML attribute values must not contain NUL (U+0000)"
+ else "HTML text must not contain NUL (U+0000)"
+ )
+ '&' -> "&"
+ '<' -> "<"
+ '>' -> ">"
+ '\r' -> "
"
+ '"' -> """
+ '\n' -> "
"
+ else -> " "
+ }
+ appendHtmlRun(value, start, index)
+ append(replacement)
+ start = index + 1
+ if (character == '&' && canAppendUnescapedHtmlSuffix(value, attribute, start)) {
+ appendHtmlRun(value, start, value.length)
+ return true
+ }
+ }
+}
+
+private fun canAppendUnescapedHtmlSuffix(value: String, attribute: Boolean, start: Int): Boolean {
+ if (value.length - start < 32) return false
+ val text = value.unsafeCast()
+ return text.indexOf('&'.code, start) < 0 && text.indexOf('<'.code, start) < 0 &&
+ text.indexOf('>'.code, start) < 0 && text.indexOf('\r'.code, start) < 0 &&
+ text.indexOf('\u0000'.code, start) < 0 &&
+ (!attribute || (text.indexOf('"'.code, start) < 0 && text.indexOf('\n'.code, start) < 0 &&
+ text.indexOf('\t'.code, start) < 0))
+}
diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HydratedDocumentToWriterTest.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HydratedDocumentToWriterTest.kt
index ed4978102a1..3278451de6c 100644
--- a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HydratedDocumentToWriterTest.kt
+++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HydratedDocumentToWriterTest.kt
@@ -107,7 +107,7 @@ class HydratedDocumentToWriterTest {
assertFalse(composed)
assertTrue(writer.chunks.isEmpty())
assertFailsWith {
- renderHydratedDocumentToStream(writer, chunkSize = 1) { Div() }
+ renderHydratedDocumentToStream(writer, chunkSize = 1, validateStrictly = true) { Div() }
}
assertEquals("", writer.chunks.joinToString(""))
assertEquals(0, writer.flushes)
diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/css/FractionalCssFormattingTest.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/css/FractionalCssFormattingTest.kt
new file mode 100644
index 00000000000..541cce338d8
--- /dev/null
+++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/css/FractionalCssFormattingTest.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 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.css
+
+import java.math.BigDecimal
+import java.math.MathContext
+import java.math.RoundingMode
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class FractionalCssFormattingTest {
+ @Test
+ fun matchesExactHalfUpReferenceAcrossBinary32Range() {
+ val random = Random(381)
+ repeat(100_000) { compare(Float.fromBits(random.nextInt())) }
+ for (exponent in -45..38) {
+ val center = java.lang.Float.parseFloat("1e$exponent").toRawBits()
+ for (offset in -32..32) {
+ compare(Float.fromBits(center + offset))
+ compare(-Float.fromBits(center + offset))
+ }
+ }
+ listOf(2070951.25f, -2070951.25f, Float.MIN_VALUE, Float.MAX_VALUE,
+ 1207866.25f, 0f, -0f, Float.NaN, Float.POSITIVE_INFINITY).forEach(::compare)
+ }
+ @Test
+ fun priorityFallbackSerializesEachValueOnce() {
+ var calls = 0
+ val value = object : StylePropertyValue {
+ override fun toString(): String {
+ calls++
+ return "0.33333334"
+ }
+ }
+ val style = StyleScopeBuilder().apply {
+ property("opacity", value, true)
+ variable("fraction", value)
+ }
+ assertEquals("opacity: 0.33333334 !important; --fraction: 0.33333334", style.toStyleAttributeValue())
+ assertEquals(2, calls)
+ }
+
+ @Test
+ fun emptyAssignmentFallbackReusesEarlierConversions() {
+ var calls = 0
+ val value = object : StylePropertyValue {
+ override fun toString(): String {
+ calls++
+ return "0.33333334"
+ }
+ }
+ val style = StyleScopeBuilder().apply {
+ property("opacity", value)
+ property("removed", "unused")
+ property("removed", "")
+ variable("fraction", value)
+ }
+ assertEquals("opacity: 0.33333334; --fraction: 0.33333334", style.toStyleAttributeValue())
+ assertEquals(2, calls)
+ }
+
+ private fun compare(value: Float) {
+ assertEquals(reference(value), formatBinary32(value.toDouble()), "bits=${value.toRawBits()}")
+ }
+ private fun reference(value: Float): String {
+ if (!value.isFinite()) return value.toString()
+ if (value == 0f) return "0"
+ val exact = BigDecimal(value.toDouble())
+ val decimal = (1..9).firstNotNullOfOrNull { precision ->
+ exact.round(MathContext(precision, RoundingMode.HALF_UP)).takeIf { it.toFloat() == value }
+ }!!.stripTrailingZeros()
+ val exponent = decimal.precision() - decimal.scale() - 1
+ if (exponent in -6..20) return decimal.toPlainString()
+ val digits = decimal.unscaledValue().abs().toString()
+ return (if (value < 0f) "-" else "") + digits[0] +
+ (if (digits.length > 1) "." + digits.substring(1) else "") +
+ "e" + (if (exponent >= 0) "+" else "") + exponent
+ }
+}
diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/LongHtmlEscapingJvmTest.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/LongHtmlEscapingJvmTest.kt
new file mode 100644
index 00000000000..bfdb53969fe
--- /dev/null
+++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/LongHtmlEscapingJvmTest.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright 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.dom
+
+import org.jetbrains.compose.web.composeHtmlToString
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class LongHtmlEscapingJvmTest {
+ @Test
+ fun specializedSerializerMatchesEveryUtf16CodeUnit() {
+ val padding = "a".repeat(128)
+ for (code in Char.MIN_VALUE.code..Char.MAX_VALUE.code) {
+ for (attribute in listOf(false, true)) {
+ for (offset in 0..3) {
+ val value = padding + "a".repeat(offset) + code.toChar() + padding
+ val output = StringBuilder("prefix:")
+ if (code == 0) {
+ assertFailsWith { output.appendEscapedHtmlString(value, attribute) }
+ } else {
+ assertTrue(output.appendEscapedHtmlString(value, attribute))
+ assertEquals("prefix:" + reference(value, attribute), output.toString(), "code=$code offset=$offset")
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun dispatchKeepsShortPathsAndHandlesThresholdsAndUnicode() {
+ val random = Random(342625)
+ val alphabet = "abc f|~@MF éα日本語😀&<>\r\"\n\t"
+ for (length in listOf(0, 31, 32, 64, 127, 128, 129, 255, 256, 257, 1024, 8192)) {
+ repeat(16) {
+ val value = buildString { repeat(length) { append(alphabet[random.nextInt(alphabet.length)]) } }
+ assertEquals(reference(value, false), buildString { appendEscapedText(value) })
+ assertEquals("",
+ composeHtmlToString { Div(attrs = { title(value) }) })
+ val output = StringBuilder("prefix:")
+ val handled = output.appendEscapedHtmlString(value, true)
+ if (length < HtmlEscapeTableMinLength) {
+ assertFalse(handled)
+ assertEquals("prefix:", output.toString())
+ } else {
+ assertTrue(handled)
+ assertEquals("prefix:" + reference(value, true), output.toString())
+ }
+ }
+ }
+ }
+
+ @Test
+ fun nativeSuffixGuardCannotSkipAnEscapeOrNul() {
+ for (character in "&<>\r\"\n\t\u0000") {
+ for (position in listOf(0, 31, 32, 127, 128, 255, 256, 1024)) {
+ val value = "&" + "a".repeat(position) + character + "a".repeat(1024)
+ for (attribute in listOf(false, true)) {
+ if (character == '\u0000') {
+ assertFailsWith {
+ StringBuilder().appendEscapedHtmlString(value, attribute)
+ }
+ } else {
+ val output = StringBuilder()
+ assertTrue(output.appendEscapedHtmlString(value, attribute))
+ assertEquals(reference(value, attribute), output.toString())
+ }
+ }
+ }
+ }
+ }
+
+ private fun reference(value: String, attribute: Boolean): String {
+ var result = value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\r", "
")
+ if (attribute) result = result.replace("\"", """).replace("\n", "
").replace("\t", " ")
+ return result
+ }
+}
diff --git a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.web.kt b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.web.kt
index cdd939ce1bc..154082042fa 100644
--- a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.web.kt
+++ b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.web.kt
@@ -10,3 +10,5 @@ internal actual fun canAppendUnescapedHtmlString(value: String, attribute: Boole
internal actual fun StringBuilder.appendHtmlRun(value: String, start: Int, end: Int) {
append(value, start, end)
}
+
+internal actual fun StringBuilder.appendEscapedHtmlString(value: String, attribute: Boolean): Boolean = false