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", composeHtmlToString(hydratable, validateStrictly = false) { + TagElement(parent, null) { Text(text) } + }) } assertEquals("<$parent>\t\n\u000C ", 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