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 `", + "", html, ) } diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/CommonInputElementsTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/CommonInputElementsTest.kt index 4d1bf4a8ed..101a9ea60c 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/CommonInputElementsTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/CommonInputElementsTest.kt @@ -40,24 +40,24 @@ class CommonInputElementsTest { } assertEquals( - "" + - "" + - "" + - "" + - "" + + "" + + "" + + "" + + "" + + "" + "" + - "" + - "" + - "" + - "" + - "" + - "" + + "" + + "" + + "" + + "" + + "" + + "" + "" + - "" + - "" + - "" + - "" + - "", + "" + + "" + + "" + + "" + + "", html, ) } diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/FormStateStringRenderingTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/FormStateStringRenderingTest.kt new file mode 100644 index 0000000000..c49d92bbcc --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/FormStateStringRenderingTest.kt @@ -0,0 +1,214 @@ +/* + * 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(org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi::class) + +package org.jetbrains.compose.web.dom + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ControlledComposition +import androidx.compose.runtime.Recomposer +import kotlinx.browser.dom.HTMLInputElement +import kotlinx.browser.dom.HTMLTextAreaElement +import kotlinx.coroutines.Dispatchers +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.attributes.setInputValue +import org.jetbrains.compose.web.attributes.setTextAreaValue +import org.jetbrains.compose.web.LocalHtmlValidationMode +import org.jetbrains.compose.web.htmlValidationMode +import org.jetbrains.compose.web.composeHtmlString +import org.jetbrains.compose.web.composeHtmlToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class FormStateStringRenderingTest { + @Test + fun serializesControlledInputValues() { + assertRenders( + "" + + "" + + "" + + "", + ) { + TextInput("Tom & \"'\n\t\ré😀") + NumberInput(12.5) + RangeInput(42) + TextInput() + } + } + + @Test + fun controlledInputValuesOverrideDefaultsInEitherOrder() { + assertRenders("") { + Input(InputType.Text) { + defaultValue("default") + value("first") + value("controlled") + defaultValue("later default") + } + Input(InputType.Text) { + value("") + defaultValue("default") + } + } + } + + @Test + fun serializesCheckboxAndRadioCheckedState() { + assertRenders( + "" + + "", + ) { + CheckboxInput(true) + CheckboxInput(false) + RadioInput(true) + RadioInput(false) + } + } + + @Test + fun controlledCheckedStateOverridesDefaultsAndUsesLastUpdate() { + assertRenders("") { + Input(InputType.Checkbox) { + checked(true) + checked(false) + defaultChecked() + } + Input(InputType.Radio) { + attr("checked", "false") + checked(false) + checked(true) + } + Input(InputType.Checkbox) { defaultChecked() } + } + } + + @Test + fun serializesTextareaValuesAndDefaultsAsEscapedText() { + assertRenders( + "" + + "" + + "", + ) { + TextArea("\nTom & \"é😀\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", "&