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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,8 @@ import org.jetbrains.compose.web.dom.LocalComposeHtmlContext
* Coroutine effects such as `LaunchedEffect` do not run. `SideEffect` and
* `DisposableEffect` still execute.
*
* Known limitations:
* - DOM property updates registered with `AttrsScope.prop(...)` are ignored because
* string rendering has no underlying DOM element.
* - Inline styles preserve CSS fallbacks, but do not fully emulate CSSOM validation and
* mutation (for example, invalid assignments that also change priority, or shorthand removal).
* Inline styles preserve CSS fallbacks but do not fully emulate CSSOM validation and mutation.
* Custom callbacks passed to `AttrsScope.prop(...)` are ignored during string rendering.
*
* On the JVM or Node, set `COMPOSE_HTML_VALIDATE_STRICTLY=true` to enable additional class and
* duplicate foreign-attribute checks by default.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,9 @@ internal val setTextAreaValue: (HTMLTextAreaElement, String) -> Unit = { e, v ->
}

internal val setTextAreaDefaultValue: (HTMLTextAreaElement, String) -> Unit = { e, v ->
e.innerText = v
if (v != e.defaultValue) {
e.defaultValue = v
}
}

internal val setCheckedValue: (HTMLInputElement, Boolean) -> Unit = { e, v ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -647,8 +647,6 @@ fun Button(
*
* Controlled state restoration requires an actual DOM element and is skipped by
* renderers that do not provide DOM element access, such as string rendering.
* TODO: Support values and checked state set through DOM properties in string rendering.
* https://youtrack.jetbrains.com/issue/CMP-10774/Support-form-state-in-Compose-HTML-EAP-string-rendering
*/
@OptIn(ComposeWebInternalApi::class)
@Composable
Expand Down Expand Up @@ -710,8 +708,6 @@ fun <K> Input(type: InputType<K>) {
* }
* ```
*
* TODO: Support string rendering of [value] and `defaultValue` as `<textarea>` text content.
* https://youtrack.jetbrains.com/issue/CMP-10774/Support-form-state-in-Compose-HTML-EAP-string-rendering
*/
@Composable
fun TextArea(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,17 @@ internal fun StringBuilder.appendStartTag(tagName: String, namespace: String, at
append('>')
}

/** Consumes a builder synchronously, preserving attribute order without making a map snapshot. */
/** Consumes a builder synchronously, preserving attribute order. */
internal fun StringBuilder.appendStartTag(
tagName: String,
namespace: String,
builder: AttrsScopeBuilder<*>,
validate: Boolean,
formState: FormControlState? = null,
): Boolean {
// Class validation runs even when an explicit class attribute overrides the class list.
val classValue = builder.classes.toClassAttributeValue(validate)
val attributes = builder.collect()
val attributes = builder.collect().withFormState(formState)
validateStringHtmlAttributes(attributes, namespace, validate, builder.hydrationProtocolAttributes)
val hasScriptSource = tagName == "script" && attributes.keys.any { it.equals("src", ignoreCase = true) }
append('<').append(tagName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ internal class HtmlStringWriterContext(
// first content is written, so these elements can stream without retaining their children.
private var pendingLeadingNewline = false

// Children of a void element still compose, but must not produce output.
private var discardingVoidContent = false
// Don't produce children's HTML, but still execute their composables.
private var discardingContent = false

// Raw text is validated at the end tag. Raw-text elements cannot nest, so one buffer suffices.
private var collectingRawText = false
Expand Down Expand Up @@ -82,7 +82,7 @@ internal class HtmlStringWriterContext(
content: (@Composable ElementScope<TElement>.() -> Unit)?,
) {
checkNotFinished()
if (discardingVoidContent) {
if (discardingContent) {
content?.invoke(elementScope())
return
}
Expand All @@ -96,7 +96,7 @@ internal class HtmlStringWriterContext(
(isHtmlRawTextElement(tagName, namespace) || (namespace == HtmlNamespace && tagName == "noscript"))
if (bufferElement) bufferedElementDepth++

val hasScriptSource = writeStartTag(
val (hasScriptSource, contentOverride) = writeStartTag(
tagName = tagName,
namespace = namespace,
applyAttrs = applyAttrs,
Expand All @@ -111,20 +111,21 @@ internal class HtmlStringWriterContext(
currentNamespace = namespace
previousSiblingWasText = false
pendingLeadingNewline = isHtmlLeadingNewlineElement(tagName, namespace)
discardingVoidContent = isVoid
collectingRawText = isHtmlRawTextElement(tagName, namespace)

flushChunk()
// For buffered elements this index stays valid until appendEndTag inspects the content.
val contentStart = output.length
if (!contentOverride.isNullOrEmpty()) writeText(contentOverride)
discardingContent = isVoid || contentOverride != null
content?.invoke(elementScope())

if (!isVoid) writeEndTag(tagName, namespace, hasScriptSource, contentStart)
currentTagName = parentTagName
currentNamespace = parentNamespace
previousSiblingWasText = false
pendingLeadingNewline = false // This element's start tag consumed its parent's first content.
discardingVoidContent = false
discardingContent = false
collectingRawText = false
if (bufferElement) bufferedElementDepth--
flushChunk()
Expand All @@ -149,7 +150,7 @@ internal class HtmlStringWriterContext(
override fun TextElement(value: String) {
checkNotFinished()
when {
discardingVoidContent -> Unit
discardingContent -> Unit
collectingRawText -> {
// Even an empty text call must go through raw-text validation at the end tag.
hasRawTextCall = true
Expand Down Expand Up @@ -196,7 +197,7 @@ internal class HtmlStringWriterContext(
namespace: String,
applyAttrs: (AttrsScope<TElement>.() -> Unit)?,
validate: Boolean,
): Boolean {
): Pair<Boolean, String?> {
requireValidHtmlTagName(tagName)
require(!collectingRawText) {
"String rendering does not support element children inside <$currentTagName>"
Expand All @@ -206,11 +207,12 @@ internal class HtmlStringWriterContext(

val attrsBuilder = attrsBuilders?.builder<TElement>(attrsBuilderIndex++) ?: AttrsScopeBuilder()
applyAttrs?.invoke(attrsBuilder)
val hasScriptSource = output.appendStartTag(tagName, namespace, attrsBuilder, validate)
val formState = attrsBuilder.propertyUpdatesOrEmpty.formControlState(tagName, namespace)
val hasScriptSource = output.appendStartTag(tagName, namespace, attrsBuilder, validate, formState)
if (attrsBuilders != null) attrsBuilder.reset()

pendingLeadingNewline = false // A child tag starts with '<', which needs no LF repair.
return hasScriptSource
return hasScriptSource to formState?.textContent
}

private fun writeEndTag(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/*
* Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/

package org.jetbrains.compose.web.dom

import androidx.compose.runtime.Composable
Expand All @@ -17,8 +22,6 @@ private fun InputAttrsScope<String>.applyAttrsWithStringValue(
* It's a controlled Input of [InputType.Checkbox].
* Controlled input means that its state is always equal [checked] value.
* If you need an uncontrolled behaviour, see [Input].
* TODO: Support [checked] in string rendering.
* https://youtrack.jetbrains.com/issue/CMP-10774/Support-form-state-in-Compose-HTML-EAP-string-rendering
*
* @see [Input] for more details on controlled and uncontrolled modes.
*/
Expand Down Expand Up @@ -155,8 +158,6 @@ fun PasswordInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit
* It's a controlled Input of [InputType.Radio].
* Controlled input means that its state is always equal [checked] value.
* If you need an uncontrolled behaviour, see [Input].
* TODO: Support [checked] in string rendering.
* https://youtrack.jetbrains.com/issue/CMP-10774/Support-form-state-in-Compose-HTML-EAP-string-rendering
*
* @see [Input] for more details on controlled and uncontrolled modes.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,13 @@ internal object StringComposeHtmlContext : ComposeHtmlContext {
update = {
val attrsScope = AttrsScopeBuilder<TElement>()
applyAttrs?.invoke(attrsScope)
val attributes = attrsScope.stringAttributes(namespace, validate)
val formState = attrsScope.propertyUpdatesOrEmpty.formControlState(elementBuilder.tagName, namespace)
val attributes = attrsScope.stringAttributes(namespace, validate, formState)
rawText?.validateAttributes(attributes.byName)

update {
set(attributes, StringHtmlNodeWrapper::updateAttributes)
set(formState?.textContent, StringHtmlNodeWrapper::updateTextContent)
}
},
scope = elementScope,
Expand Down Expand Up @@ -165,7 +167,8 @@ private fun unavailableDomElement(): Nothing =
/*
Only reads: ordinary HTML attributes, classes, styleScope.properties, styleScope.variables
Event listeners are intentionally ignored because they are client-side behavior, not string HTML behaviour.
TODO not supported: attrsScope.refEffect, propertyUpdates
Known form-control property setters override the serialized attributes and textarea text.
DOM refs and arbitrary property callbacks are not executed.

```kotlin
Div({
Expand All @@ -178,11 +181,12 @@ private fun unavailableDomElement(): Nothing =
internal fun <TElement : Element> AttrsScopeBuilder<TElement>.stringAttributes(
namespace: String,
validate: Boolean,
formState: FormControlState? = null,
): StringHtmlAttributes {
val classAttributeValue = classes.toClassAttributeValue(validate)
val existingStyleScope = styleScopeOrNull
return StringHtmlAttributes.from(
attributes = collect(),
attributes = collect().withFormState(formState),
namespace = namespace,
validate = validate,
hydrationProtocolAttributes = hydrationProtocolAttributes,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/

package org.jetbrains.compose.web.dom

import kotlinx.browser.dom.Element
import org.jetbrains.compose.web.attributes.setCheckedValue
import org.jetbrains.compose.web.attributes.setInputValue
import org.jetbrains.compose.web.attributes.setTextAreaDefaultValue
import org.jetbrains.compose.web.attributes.setTextAreaValue

/** Null fields preserve attributes or children; empty text replaces children. */
internal class FormControlState(
val value: String? = null,
val checked: Boolean? = null,
val textContent: String? = null,
)

internal fun Map<String, String>.withFormState(state: FormControlState?): Map<String, String> {
if (state == null || (state.value == null && state.checked == null)) return this
return LinkedHashMap(this).apply {
state.value?.let { put("value", it) }
// Controlled false removes even an explicitly supplied checked attribute.
when (state.checked) {
true -> put("checked", "")
false -> remove("checked")
null -> Unit
}
}
}

/** Reads known form setters without invoking DOM callbacks or changing the attribute builder. */
internal fun List<Pair<(Element, Any) -> Unit, Any>>.formControlState(
tagName: String,
namespace: String?,
): FormControlState? {
if (isEmpty() || namespace != HtmlNamespace) return null

return when (tagName) {
"input" -> {
var inputValue: String? = null
var checked: Boolean? = null
for ((update, value) in this) {
when {
update === setInputValue -> inputValue = value as String
update === setCheckedValue -> checked = value as Boolean
}
}
if (inputValue == null && checked == null) null else FormControlState(inputValue, checked)
}
"textarea" -> {
var textAreaValue: String? = null
var textAreaDefaultValue: String? = null
for ((update, value) in this) {
when {
update === setTextAreaValue -> textAreaValue = value as String
update === setTextAreaDefaultValue -> textAreaDefaultValue = value as String
}
}
// A controlled value takes precedence over a default, including an explicitly empty value.
(textAreaValue ?: textAreaDefaultValue)?.let { FormControlState(textContent = it) }
}
else -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ internal class StringHtmlNodeWrapper(
requireElement("update attributes").updateAttributes(attributes)
}

fun updateTextContent(value: String?) {
requireElement("update text content").textContent = value
}

fun updateText(value: String) {
val textNode = node as? StringHtmlTextNode
?: error("Cannot update text on an element node")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal class StringHtmlElementNode private constructor(
}
internal val children: MutableList<StringHtmlNode> = mutableListOf()
private var attributes: Map<String, String> = emptyMap()
internal var textContent: String? = null

constructor(
tagName: String,
Expand Down Expand Up @@ -71,7 +72,10 @@ internal class StringHtmlElementNode private constructor(
val contentStart = builder.length
// The parent determines text serialization: script/style and other raw-text elements
// emit validated text without HTML escaping. Ordinary elements escape their text.
if (isHtmlRawTextElement(tagName, namespace) && children.isNotEmpty()) {
val textContent = textContent
if (textContent != null) {
builder.appendEscapedText(textContent)
} else if (isHtmlRawTextElement(tagName, namespace) && children.isNotEmpty()) {
// Validate together so end tags split across children cannot bypass validation.
val text = children.joinToString("") { child ->
when (child) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ class CommonElementsTest {
}

@Test
fun skipsControlledInputPropertiesWithoutDomElementAccess() {
fun rendersControlledInputProperties() {
val html = composeHtmlToString {
Input(InputType.Text) {
value("controlled")
Expand All @@ -413,13 +413,13 @@ class CommonElementsTest {
}

assertEquals(
"<input type=\"text\"><input type=\"checkbox\">",
"<input type=\"text\" value=\"controlled\"><input type=\"checkbox\" checked>",
html,
)
}

@Test
fun rendersTextAreaAttributesWithoutDomElementAccess() {
fun rendersTextAreaAttributes() {
val html = composeHtmlToString {
TextArea(
attrs = {
Expand All @@ -438,7 +438,7 @@ class CommonElementsTest {
}

@Test
fun skipsTextAreaValuePropertiesWithoutDomElementAccess() {
fun rendersTextAreaValueProperties() {
val html = composeHtmlToString {
TextArea(value = "controlled")
TextArea {
Expand All @@ -447,7 +447,7 @@ class CommonElementsTest {
}

assertEquals(
"<textarea></textarea><textarea></textarea>",
"<textarea>controlled</textarea><textarea>default</textarea>",
html,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,24 @@ class CommonInputElementsTest {
}

assertEquals(
"<input type=\"checkbox\">" +
"<input type=\"date\">" +
"<input type=\"datetime-local\">" +
"<input type=\"email\">" +
"<input type=\"file\">" +
"<input type=\"checkbox\" checked>" +
"<input type=\"date\" value=\"2026-08-18\">" +
"<input type=\"datetime-local\" value=\"2026-08-18T12:30\">" +
"<input type=\"email\" value=\"user@example.com\">" +
"<input type=\"file\" value=\"ignored.txt\">" +
"<input type=\"hidden\" value=\"token\">" +
"<input type=\"month\">" +
"<input type=\"number\" min=\"1\" max=\"20\">" +
"<input type=\"password\">" +
"<input type=\"radio\" value=\"choice\">" +
"<input type=\"range\" min=\"0\" max=\"10\" step=\"2\">" +
"<input type=\"search\">" +
"<input type=\"month\" value=\"2026-08\">" +
"<input type=\"number\" min=\"1\" max=\"20\" value=\"10\">" +
"<input type=\"password\" value=\"secret\">" +
"<input type=\"radio\" value=\"choice\" checked>" +
"<input type=\"range\" min=\"0\" max=\"10\" step=\"2\" value=\"5\">" +
"<input type=\"search\" value=\"query\">" +
"<input type=\"submit\" value=\"Send\">" +
"<input type=\"tel\">" +
"<input type=\"text\">" +
"<input type=\"time\">" +
"<input type=\"url\">" +
"<input type=\"week\">",
"<input type=\"tel\" value=\"+41 12 345 67 89\">" +
"<input type=\"text\" value=\"text\">" +
"<input type=\"time\" value=\"12:30\">" +
"<input type=\"url\" value=\"https://example.com\">" +
"<input type=\"week\" value=\"2026-W34\">",
html,
)
}
Expand Down
Loading
Loading