diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToString.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToString.kt
index a3f3ab8e4b..5e644730ef 100644
--- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToString.kt
+++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToString.kt
@@ -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.
diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt
index 02fb752372..9f67fd95ad 100644
--- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt
+++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt
@@ -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 ->
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 8fe6ce3b1f..e855ab4b98 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
@@ -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
@@ -710,8 +708,6 @@ fun Input(type: InputType) {
* }
* ```
*
- * TODO: Support string rendering of [value] and `defaultValue` as ` \"é😀\r")
+ TextArea { defaultValue("default & ") }
+ TextArea { value("attribute value") }
+ TextArea("")
+ }
+ }
+
+ @Test
+ fun controlledTextareaValueOverridesDefaultsInEitherOrder() {
+ assertRenders("") {
+ TextArea("controlled") { defaultValue("default") }
+ TextArea {
+ value("first")
+ defaultValue("default")
+ value("")
+ defaultValue("later default")
+ }
+ TextArea {
+ defaultValue("first default")
+ defaultValue("last default")
+ }
+ }
+ }
+
+ @Test
+ fun textareaValueReplacesChildTextWhileChildrenStillCompose() {
+ var composed = 0
+ assertRenders("after") {
+ TagElement("textarea", { prop(setTextAreaValue, "value") }) {
+ composed++
+ Text("child")
+ }
+ Span { Text("after") }
+ }
+ assertEquals(16, composed)
+ }
+
+ @Test
+ fun arbitraryPropertyCallbacksAreNotInvokedOrSerialized() {
+ assertRenders("") {
+ Input(InputType.Text) {
+ prop({ _: HTMLInputElement, _: String -> error("DOM callback must not execute") }, "ignored")
+ }
+ TextArea {
+ prop({ _: HTMLTextAreaElement, _: String -> error("DOM callback must not execute") }, "ignored")
+ }
+ }
+ }
+
+ @Test
+ fun formPropertiesDoNotAffectOtherTagsOrNamespaces() {
+ assertRenders("") {
+ Div({ prop(setInputValue, "ignored") }) {}
+ TagElementNS("input", "urn:example", {
+ prop(setInputValue, "ignored")
+ }, null)
+ }
+ }
+
+ @Test
+ fun keyedRendersClearFormStateBetweenRequests() {
+ for (includeState in listOf(true, false, true, false)) {
+ val content: @Composable () -> Unit = {
+ Input(InputType.Text) { if (includeState) value("value") }
+ Input(InputType.Checkbox) { if (includeState) checked(true) }
+ TextArea { if (includeState) defaultValue("default") }
+ }
+ assertEquals(
+ composeHtmlToString(content = content),
+ composeHtmlToString(key = "form-state-reset", content = content),
+ )
+ }
+ }
+
+ @Test
+ fun formStateRejectsNulCharacters() {
+ for (content in listOf<@Composable () -> Unit>(
+ { TextInput("a\u0000b") },
+ { TextArea("a\u0000b") },
+ { TextArea { defaultValue("a\u0000b") } },
+ )) {
+ assertFailsWith { composeHtmlToString(content = content) }
+ }
+ }
+
+ private fun assertRenders(expected: String, content: @Composable () -> Unit) {
+ for (hydratable in listOf(false, true)) {
+ for (strict in listOf(false, true)) {
+ assertEquals(expected, composeHtmlToString(hydratable, validateStrictly = strict, content = content))
+ assertEquals(expected, composeHtmlToString(hydratable, "form-state", strict, content))
+ val chunks = StringBuilder()
+ val context: @Composable () -> Unit = {
+ CompositionLocalProvider(
+ LocalHtmlValidationMode provides htmlValidationMode(strict),
+ ) { content() }
+ }
+ composeHtmlString(hydratable, chunkSink = { chunks.append(it) }, chunkSize = 1, content = context)
+ assertEquals(expected, chunks.toString())
+ assertEquals(expected, renderTree(hydratable, context))
+ }
+ }
+ }
+
+ private fun renderTree(hydratable: Boolean, content: @Composable () -> Unit): String {
+ val root = StringHtmlElementNode.root()
+ val recomposer = Recomposer(Dispatchers.Default)
+ val composition = ControlledComposition(StringHtmlApplier(StringHtmlNodeWrapper(root)), recomposer)
+ try {
+ composition.setContent {
+ CompositionLocalProvider(LocalComposeHtmlContext provides StringComposeHtmlContext) { content() }
+ }
+ return root.toHtmlString(hydratable)
+ } finally {
+ composition.dispose()
+ recomposer.close()
+ }
+ }
+}
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/StringHtmlRecompositionTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/StringHtmlRecompositionTest.kt
index b776a9fcc6..7f13bd0630 100644
--- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/StringHtmlRecompositionTest.kt
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/StringHtmlRecompositionTest.kt
@@ -11,7 +11,9 @@ import androidx.compose.runtime.ControlledComposition
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.snapshots.Snapshot
import kotlinx.coroutines.Dispatchers
+import org.jetbrains.compose.web.attributes.InputType
import org.jetbrains.compose.web.css.Color
import org.jetbrains.compose.web.css.color
import kotlin.test.Test
@@ -74,6 +76,33 @@ class StringHtmlRecompositionTest {
}
}
+ @Test
+ fun updatesAndRemovesFormControlProperties() = withStringComposition { composition ->
+ val formState = mutableStateOf>("first" to true)
+ composition.setContent {
+ val (value, checked) = formState.value
+ Input(InputType.Text) { value?.let { value(it) } }
+ CheckboxInput(checked)
+ TextArea { value?.let { value(it) } }
+ }
+ assertEquals(
+ "",
+ composition.toHtmlString(),
+ )
+
+ formState.value = "second & " to false
+ composition.recomposeAfter(formState)
+ assertEquals(
+ "" +
+ "",
+ composition.toHtmlString(),
+ )
+
+ formState.value = null to false
+ composition.recomposeAfter(formState)
+ assertEquals("", composition.toHtmlString())
+ }
+
@Test
fun updatesSerializedStyleRules() = withStringComposition { composition ->
val ruleColor = mutableStateOf(Color.red)
@@ -140,7 +169,15 @@ private class TestStringComposition {
fun recomposeAfter(changed: Any) {
composition.recordModificationsOf(setOf(changed))
- if (composition.recompose()) {
+ // Match the recomposer's snapshot observers so later updates retain their read tracking.
+ val snapshot = Snapshot.takeMutableSnapshot(composition::recordReadOf, composition::recordWriteOf)
+ val recomposed = try {
+ snapshot.enter { composition.recompose() }
+ } finally {
+ snapshot.apply()
+ snapshot.dispose()
+ }
+ if (recomposed) {
composition.applyChanges()
composition.applyLateChanges()
}
diff --git a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/Base.web.kt b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/Base.web.kt
index 0bece2db6b..9f58efa989 100644
--- a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/Base.web.kt
+++ b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/Base.web.kt
@@ -113,7 +113,7 @@ private open class DomElementWrapper(override val node: Element) : DomNodeWrappe
if (previous == null || previousClasses != classes) updateClasses(classes)
if (previous == null || previousStyle != style) updateStyleDeclarations(style)
- if (previous == null || previous.collect() != attrs) updateAttrs(attrs)
+ if (previous == null || previous.collect() != attrs) updateAttrs(attrs, next.propertyUpdatesOrEmpty)
if (previous == null || previous.propertyUpdatesOrEmpty != next.propertyUpdatesOrEmpty) {
updateProperties(next.propertyUpdatesOrEmpty)
}
@@ -162,7 +162,10 @@ private open class DomElementWrapper(override val node: Element) : DomNodeWrappe
}
}
- open fun updateAttrs(attrs: Map) {
+ open fun updateAttrs(
+ attrs: Map,
+ properties: List Unit, Any>>,
+ ) {
node.getAttributeNames().toList().forEach { jsName ->
val name = jsName.toKotlinString()
if (name != "style" && name != AttrsScope.CLASS && name !in attrs) {
@@ -210,14 +213,24 @@ private class HydratingDomElementWrapper(
override val allowsHydrationMismatch: Boolean
get() = allowance.isAllowed
- override fun updateAttrs(attrs: Map) {
+ override fun updateAttrs(
+ attrs: Map,
+ properties: List Unit, Any>>,
+ ) {
if (!applier.isHydrating) {
- super.updateAttrs(attrs)
+ super.updateAttrs(attrs, properties)
return
}
if (applier.validationMode == HtmlValidationMode.Fast && !allowance.isAllowed) return
+ val formState = properties.formControlState(node.localName, node.namespaceURI)
attrs.forEach { (name, value) ->
+ // SSR encodes controlled properties in these attributes. Apply the client state after hydration.
+ if ((name == "value" && formState?.value != null) ||
+ (name == "checked" && formState?.checked != null)
+ ) {
+ return@forEach
+ }
verifyAttribute(name, expected = value) {
// Unrelated server attributes are tolerated, so only the composed one is patched.
node.setComposedAttribute(name, value)
@@ -259,6 +272,11 @@ private class HydratingDomElementWrapper(
override fun updateProperties(applicators: List Unit, Any>>) {
if (applicators.isEmpty()) return
+ if (applier.isHydrating &&
+ applicators.formControlState(node.localName, node.namespaceURI)?.textContent != null
+ ) {
+ applier.claimTextAreaValueContent(node)
+ }
applier.applyOrDeferDomMutation {
super.updateProperties(applicators)
}
diff --git a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/HydrationDomApplier.web.kt b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/HydrationDomApplier.web.kt
index 42c0e79066..447ba2f1f2 100644
--- a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/HydrationDomApplier.web.kt
+++ b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/HydrationDomApplier.web.kt
@@ -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.
+ */
+
@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class)
package org.jetbrains.compose.web.dom
@@ -147,6 +152,23 @@ internal class HydrationDomApplier(
return element
}
+ /** Textarea value setters own its serialized text, which is not a Compose-managed child. */
+ fun claimTextAreaValueContent(element: Element) {
+ ensureHydrating()
+ var child = element.firstChild
+ while (child != null) {
+ if (child !is Text) {
+ mismatchAtCurrentNode("expected textarea value text, found ${child.describe()}")
+ }
+ child = child.nextSibling
+ }
+ if (currentFrame.node === element) {
+ currentFrame.nextNode = null
+ } else {
+ nodesWithClaimedRawChildren += element
+ }
+ }
+
/** Claims the next logical text node. Boundary markers prevent browser merging. */
fun claimText(value: String): Text {
ensureHydrating()
diff --git a/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/FormStateStringRenderingBrowserTest.kt b/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/FormStateStringRenderingBrowserTest.kt
new file mode 100644
index 0000000000..43feada40c
--- /dev/null
+++ b/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/FormStateStringRenderingBrowserTest.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
+ */
+
+package org.jetbrains.compose.web
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kotlinx.browser.dom.HTMLElement
+import kotlinx.browser.dom.HTMLInputElement
+import kotlinx.browser.dom.HTMLTextAreaElement
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.promise
+import org.jetbrains.compose.web.attributes.InputType
+import org.jetbrains.compose.web.dom.CheckboxInput
+import org.jetbrains.compose.web.dom.Input
+import org.jetbrains.compose.web.dom.RadioInput
+import org.jetbrains.compose.web.dom.TextArea
+import org.jetbrains.compose.web.dom.TextInput
+import org.jetbrains.compose.web.internal.runtime.browserDocument
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertFailsWith
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.milliseconds
+
+class FormStateStringRenderingBrowserTest {
+ @Test
+ fun clientRenderedTextareaDefaultsPreserveNewlinesAndUserEdits() = MainScope().promise {
+ val root = browserDocument.createElement("div") as HTMLElement
+ val value = "first\nsecond & "
+ var default by mutableStateOf(value)
+ val composition = renderComposable(root) { TextArea { defaultValue(default) } }
+ try {
+ val textarea = root.firstElementChild as HTMLTextAreaElement
+ assertEquals(value, textarea.value)
+ assertEquals(value, textarea.textContent)
+ textarea.value = "typed"
+ default = "new\ndefault"
+ delay(100.milliseconds)
+ assertEquals("typed", textarea.value)
+ assertEquals("new\ndefault", textarea.textContent)
+ } finally {
+ composition.dispose()
+ }
+ }
+
+ @Test
+ fun controlledInputOverridesHydrateWithConflictingDefaults() {
+ val content: @Composable () -> Unit = {
+ TextInput("controlled") { defaultValue("default") }
+ CheckboxInput(false) { defaultChecked() }
+ RadioInput(false) { attr("checked", "false") }
+ }
+ for (strict in listOf(false, true)) {
+ val root = browserDocument.createElement("div") as HTMLElement
+ root.innerHTML = composeHtmlToString(content = content)
+ val input = root.firstElementChild as HTMLInputElement
+ val composition = hydrateComposable(root, validateStrictly = strict, onHydrationMismatch = { throw it }) {
+ content()
+ }
+ try {
+ assertSame(input, root.firstElementChild)
+ assertEquals("controlled", input.value)
+ assertFalse((root.children.item(1) as HTMLInputElement).checked)
+ assertFalse((root.children.item(2) as HTMLInputElement).checked)
+ } finally {
+ composition.dispose()
+ }
+ }
+ }
+
+ @Test
+ fun controlledInputStateCanChangeDuringHydrationWithDefaults() {
+ for (strict in listOf(false, true)) {
+ for ((value, checked) in listOf("client" to true, "" to false)) {
+ val root = browserDocument.createElement("div") as HTMLElement
+ root.innerHTML = composeHtmlToString {
+ TextInput("server") { defaultValue("default") }
+ CheckboxInput(!checked) { defaultChecked() }
+ RadioInput(!checked) { defaultChecked() }
+ }
+ val inputs = (0..2).map { root.children.item(it) as HTMLInputElement }
+ val composition = hydrateComposable(root, validateStrictly = strict, onHydrationMismatch = { throw it }) {
+ TextInput(value) { defaultValue("default") }
+ CheckboxInput(checked) { defaultChecked() }
+ RadioInput(checked) { defaultChecked() }
+ }
+ try {
+ inputs.forEachIndexed { index, input -> assertSame(input, root.children.item(index)) }
+ assertEquals(value, inputs[0].value)
+ assertEquals(checked, inputs[1].checked)
+ assertEquals(checked, inputs[2].checked)
+ } finally {
+ composition.dispose()
+ }
+ }
+ }
+ }
+
+ @Test
+ fun controlledInputStateDoesNotSuppressOtherAttributeMismatches() {
+ val root = browserDocument.createElement("div") as HTMLElement
+ root.innerHTML = composeHtmlToString {
+ TextInput("server") {
+ defaultValue("default")
+ attr("data-kind", "server")
+ }
+ }
+ assertFailsWith {
+ hydrateComposable(root, validateStrictly = true, onHydrationMismatch = { throw it }) {
+ TextInput("client") {
+ defaultValue("default")
+ attr("data-kind", "client")
+ }
+ }
+ }
+ }
+
+ @Test
+ fun parsedInputsRestoreValuesAndCheckedState() {
+ val root = browserDocument.createElement("div") as HTMLElement
+ val value = "Tom & \"'é😀"
+ root.innerHTML = composeHtmlToString {
+ TextInput(value)
+ CheckboxInput(true)
+ CheckboxInput(false) { defaultChecked() }
+ RadioInput(true)
+ RadioInput(false) { defaultChecked() }
+ }
+ val inputs = root.querySelectorAll("input")
+ assertEquals(value, (inputs.item(0) as HTMLInputElement).value)
+ for (index in 1..4) {
+ val input = inputs.item(index) as HTMLInputElement
+ val checked = index % 2 == 1
+ assertEquals(checked, input.checked)
+ assertEquals(checked, input.hasAttribute("checked"))
+ }
+ }
+
+ @Test
+ fun parsedTextareasRestoreEscapedValuesAndLeadingNewlines() {
+ val values = listOf("", "hello", "\nhello", "\n\nhello", "&