{
+ composeHtmlToString(key = "caller-snapshot-failure") {
+ state.value = "failed render"
+ error("content failed")
+ }
+ }
+ assertEquals("failed render", state.value)
+ state.value = "recovered"
+ assertEquals("recovered", composeHtmlToString(key = "caller-snapshot-failure") {
+ Text(state.value)
+ })
+ }
+ assertEquals("outside", state.value)
+ } finally {
+ snapshot.dispose()
+ }
}
@Test
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt
index 56bb30016a..68739880e4 100644
--- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt
@@ -80,7 +80,7 @@ class DirectStringRenderingTest {
}
@Test
- fun preservesLocalsSnapshotsAndEffectLifecycle() {
+ fun preservesLocalsStateWritesAndEffectLifecycle() {
val local = staticCompositionLocalOf { "default" }
val state = mutableStateOf("outside")
var remembered = 0
@@ -103,7 +103,7 @@ class DirectStringRenderingTest {
}
}
assertEquals("local-$request:$request:${request + 1}
", html)
- assertEquals("outside", state.value)
+ assertEquals("$request", state.value)
}
assertEquals((0..2).flatMap { listOf("enter $it", "side $it", "dispose $it") }, effects)
assertFalse(launched)
@@ -124,7 +124,6 @@ class DirectStringRenderingTest {
{ Div({ attr("data-value", "bad\u0000attribute") }) },
{ TagElement("script", null) { Text("") } },
{ TagElement("style", null) { Span { Text("child") } } },
- { Table { Tr { Td { Text("missing tbody") } } } },
)
for (content in invalid) {
assertFailsWith { composeHtmlToString(content = content) }
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlAttributeNameCacheTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlAttributeNameCacheTest.kt
new file mode 100644
index 0000000000..a7aa1620c6
--- /dev/null
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlAttributeNameCacheTest.kt
@@ -0,0 +1,22 @@
+/*
+ * 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 kotlin.test.*
+
+class HtmlAttributeNameCacheTest {
+ @Test
+ fun namesAreBoundedAndInvalidNamesNeverEnterTheCache() {
+ val cache = HtmlAttributeNameCache()
+ repeat(1000) { cache.requireValid("data-$it") }
+ assertEquals(128, cache.size)
+ cache.requireValid("data-" + "a".repeat(1000))
+ assertEquals(128, cache.size)
+ for (name in listOf("", "a b", "a\u0000", "a\u009F", "a'", "a=", "a/", "a>")) {
+ repeat(2) { assertFailsWith { cache.requireValid(name) } }
+ }
+ assertEquals(128, cache.size)
+ }
+}
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlNamespaceEscapingTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlNamespaceEscapingTest.kt
new file mode 100644
index 0000000000..c4bb8659e5
--- /dev/null
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlNamespaceEscapingTest.kt
@@ -0,0 +1,63 @@
+/*
+ * 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 kotlinx.browser.dom.Element
+import org.jetbrains.compose.web.composeHtmlToString
+import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+@OptIn(ComposeWebInternalApi::class)
+class HtmlNamespaceEscapingTest {
+ @Test
+ fun htmlAndForeignNamespacesKeepTheSameEntityRules() {
+ val prefix = "é😀".repeat(40)
+ val cases = listOf(
+ Triple("0123456789", "0123456789", "0123456789"),
+ Triple("<&>\"\n\t\r", "<&>"
", "<&>\"\n\t
"),
+ Triple(prefix + "&\"", prefix + "&"", prefix + "&\""),
+ )
+ for ((value, attribute, text) in cases) {
+ for ((namespace, tag) in listOf(HtmlNamespace to "div", "http://www.w3.org/2000/svg" to "svg")) {
+ assertEquals("<$tag title=\"$attribute\">$text$tag>", composeHtmlToString(validateStrictly = true) {
+ TagElementNS(tag, namespace, { attr("title", value) }) { Text(value) }
+ })
+ }
+ }
+ }
+
+ @Test
+ fun pooledRendersCanAlternateNamespacesAndValues() {
+ repeat(8) { index ->
+ val namespace = if (index % 2 == 0) HtmlNamespace else "http://www.w3.org/2000/svg"
+ val tag = if (index % 2 == 0) "div" else "svg"
+ assertEquals("<$tag title=\"$index&\">$index<$tag>", composeHtmlToString(key = "mixed-namespace-escaping", validateStrictly = true) {
+ TagElementNS(tag, namespace, { attr("title", "$index&") }) { Text("$index<") }
+ })
+ }
+ }
+
+ @Test
+ fun foreignNulIsRejectedAfterUnescapedPooledValues() {
+ val namespace = "http://www.w3.org/2000/svg"
+ assertEquals("", composeHtmlToString(key = "foreign-escaping-nul") {
+ TagElementNS("svg", namespace, { attr("title", "safe") }) { Text("safe") }
+ })
+ for (value in listOf("\u0000", "é😀".repeat(40) + '\u0000')) {
+ assertFailsWith {
+ composeHtmlToString(key = "foreign-escaping-nul") {
+ TagElementNS("svg", namespace, { attr("title", value) }, null)
+ }
+ }
+ assertFailsWith {
+ composeHtmlToString(key = "foreign-escaping-nul") {
+ TagElementNS("svg", namespace, null) { Text(value) }
+ }
+ }
+ }
+ }
+}
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 9d9369b8e4..6fe804ce52 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
@@ -6,6 +6,12 @@
package org.jetbrains.compose.web.dom
import androidx.compose.runtime.Composable
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotSame
+import kotlin.test.assertSame
import kotlinx.browser.dom.Element
import kotlinx.browser.dom.HTMLDivElement
import kotlinx.browser.dom.HTMLInputElement
@@ -14,13 +20,7 @@ import org.jetbrains.compose.web.attributes.disabled
import org.jetbrains.compose.web.attributes.readOnly
import org.jetbrains.compose.web.attributes.required
import org.jetbrains.compose.web.composeHtmlToString
-import kotlin.test.Test
-import kotlin.test.assertContains
-import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi
-import kotlin.test.assertNotSame
-import kotlin.test.assertSame
internal const val TestSvgNamespace = "http://www.w3.org/2000/svg"
@@ -70,7 +70,7 @@ class HtmlSerializationTest {
}
@Test
- fun rejectsTableChildrenThatRequireParserRepair() {
+ fun strictModeRejectsTableChildrenThatRequireParserRepair() {
val invalidChildren = mapOf(
"table" to listOf("tr", "td", "th", "col", "div"),
"tbody" to listOf("td", "th", "div"),
@@ -83,7 +83,7 @@ class HtmlSerializationTest {
invalidChildren.forEach { (parent, children) ->
children.forEach { child ->
val failure = assertFailsWith("$parent > $child") {
- composeHtmlToString(hydratable) {
+ composeHtmlToString(hydratable, validateStrictly = true) {
TagElement(parent, null) {
TagElement(child, null, null)
}
@@ -193,6 +193,24 @@ class HtmlSerializationTest {
)
}
+ @Test
+ fun fastModeSkipsNameAndTableElementChecksWithoutAffectingStrictRenders() {
+ val cases: List Unit>> = listOf(
+ "" to { Div({ attr("invalid name", "value") }) },
+ "" to { TagElement("invalid tag", null, null) },
+ "" to { Table { Tr { Td { Text("cell") } } } },
+ )
+ for (key in listOf(null, "validation-mode-switch")) {
+ for ((expected, content) in cases) {
+ assertEquals(expected, composeHtmlToString(key = key, validateStrictly = false, content = content))
+ assertFailsWith {
+ composeHtmlToString(key = key, validateStrictly = true, content = content)
+ }
+ assertEquals(expected, composeHtmlToString(key = key, validateStrictly = false, content = content))
+ }
+ }
+ }
+
@Test
fun noscriptEscapesTextAndAllowsFallbackElements() {
assertEquals(
@@ -746,4 +764,130 @@ class HtmlSerializationTest {
html,
)
}
+ @Test
+ fun attributeNameChecksRejectAllControlsAndProtocolSpoofing() {
+ for (character in (0..31).map(Int::toChar) + (127..159).map(Int::toChar) + " \"'/>=".toList()) {
+ assertFailsWith {
+ composeHtmlToString(validateStrictly = true) { Div(attrs = { attr("prefix${character}suffix", "value") }) }
+ }
+ }
+ for (name in listOf("data-compose-hydration-root", "DATA-COMPOSE-HYDRATION-STATE",
+ "Data-Compose-Hydration-For", "data-compose-hydration-validation")) {
+ assertFailsWith {
+ composeHtmlToString { Div(attrs = { attr(name, "value") }) }
+ }
+ }
+ assertEquals("", composeHtmlToString {
+ Div(attrs = { attr("data-compose-hydration-other", "allowed") })
+ })
+ }
+
+ @Test
+ fun pooledAttributeNamesNeverCacheValues() {
+ repeat(6) { i ->
+ assertEquals("", composeHtmlToString(key = "attribute-name-reuse") {
+ Div({ attr("data-long-name-for-validation", "$i&\"") })
+ })
+ }
+ assertFailsWith {
+ composeHtmlToString(key = "attribute-name-reuse", validateStrictly = true) {
+ Div({ attr("invalid name", "value") })
+ }
+ }
+ assertEquals("", composeHtmlToString(key = "attribute-name-reuse") { Div({ title("next") }) })
+ }
+
+ @Test
+ fun reservedAttributeOwnershipIsCheckedAfterNameReuse() {
+ val reserved = "data-compose-hydration-state"
+ repeat(2) {
+ assertFailsWith {
+ composeHtmlToString(key = "reserved-attribute-reuse") { Div({ attr(reserved, "forged") }) }
+ }
+ }
+ }
+
+ @Test
+ fun foreignDuplicateChecksSurviveRepeatedNames() {
+ repeat(2) {
+ assertFailsWith {
+ composeHtmlToString(key = "foreign-attribute-reuse", validateStrictly = true) {
+ TagElementNS("g", "http://www.w3.org/2000/svg",
+ {
+ attr("viewBox", "first")
+ attr("viewbox", "second")
+ }, null)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun escapingPreservesEntitiesAndUnicodeAcrossFastPathLengths() {
+ for (length in listOf(0, 1, 30, 31, 32, 33, 63, 64, 65, 1024)) {
+ val prefix = "é😀a".repeat(length)
+ val suffix = "<&>\r\"\n\t&"
+ val html = composeHtmlToString {
+ Div(attrs = { attr("title", prefix + suffix) }) { Text(prefix + suffix) }
+ }
+ assertEquals(
+ "" +
+ "${prefix}<&>
\"\n\t&
",
+ html,
+ )
+ }
+ }
+
+ @Test
+ fun longUnescapedStringsStillRejectNul() {
+ for (position in listOf(0, 31, 32, 1000)) {
+ val value = "x".repeat(position) + '\u0000' + "y".repeat(1000)
+ assertFailsWith { composeHtmlToString { Text(value) } }
+ assertFailsWith {
+ composeHtmlToString { Div(attrs = { attr("title", value) }) }
+ }
+ }
+ }
+
+ @Test
+ fun classTokensEscapeOnceAndExplicitClassStillOverrides() {
+ for (strict in listOf(false, true)) {
+ assertEquals("", composeHtmlToString(validateStrictly = strict) {
+ Div(attrs = { classes("a&", "b\"") })
+ })
+ assertEquals("", composeHtmlToString(validateStrictly = strict) {
+ Div(attrs = {
+ classes("a&", "b\"")
+ attr("class", "explicit")
+ })
+ })
+ }
+ assertFailsWith {
+ composeHtmlToString(validateStrictly = true) {
+ Div(attrs = {
+ classes("invalid token")
+ attr("class", "explicit")
+ })
+ }
+ }
+ }
+
+ @Test
+ fun rendererBuilderCacheSeparatesNamespacesAndStopsAtItsBound() {
+ val pool = AttrsBuilderPool()
+ val html = pool.elementBuilder("script", HtmlNamespace)
+ val svg = pool.elementBuilder("script", TestSvgNamespace)
+ assertSame(html, pool.elementBuilder("script", HtmlNamespace))
+ assertSame(svg, pool.elementBuilder("script", TestSvgNamespace))
+ assertNotSame(html, svg)
+ assertEquals(RawTextTagFlag or ValidTagFlag, (html as HtmlTagMetadata).serializationFlags)
+ assertEquals(ValidTagFlag, (svg as HtmlTagMetadata).serializationFlags)
+ repeat(126) { pool.elementBuilder("tag-$it", TestSvgNamespace) }
+ assertNotSame(
+ pool.elementBuilder("uncached", TestSvgNamespace),
+ pool.elementBuilder("uncached", TestSvgNamespace),
+ )
+ assertSame(svg, pool.elementBuilder("script", TestSvgNamespace))
+ }
+
}
diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/StringStyleTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/StringStyleTest.kt
index db38e9bec4..46272cef29 100644
--- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/StringStyleTest.kt
+++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/StringStyleTest.kt
@@ -5,15 +5,12 @@
package org.jetbrains.compose.web.dom
-import org.jetbrains.compose.web.composeHtmlToString
-import org.jetbrains.compose.web.css.Color
-import org.jetbrains.compose.web.css.Style
-import org.jetbrains.compose.web.css.StyleSheet
-import org.jetbrains.compose.web.css.color
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
+import org.jetbrains.compose.web.composeHtmlToString
+import org.jetbrains.compose.web.css.*
class StringStyleTest {
@Test
@@ -69,4 +66,95 @@ class StringStyleTest {
assertContains(failure.message.orEmpty(), "Raw text for ",
+ composeHtmlToString(key = "stylesheet-rule-reuse") { Style(cssRules = rules) })
+ }
+ }
+
+ @Test
+ fun emptyAssignmentsAndPriorityResetKeepTheirOrder() {
+ assertEquals(
+ "",
+ composeHtmlToString {
+ Div(attrs = { style {
+ property("color", "red")
+ property("color", "")
+ property("color", "blue")
+ property("display", "block", important = true)
+ property("display", "flex")
+ variable("accent", "red")
+ variable("accent", "")
+ variable("accent", "blue")
+ } })
+ },
+ )
+ assertEquals("", composeHtmlToString {
+ Div(attrs = {
+ style {
+ property("color", "red")
+ property("color", "")
+ }
+ })
+ })
+ }
+
}
diff --git a/html/eap/core/src/jsTest/kotlin/elements/ElementsTests.kt b/html/eap/core/src/jsTest/kotlin/elements/ElementsTests.kt
index bd125ca9d7..4337efd804 100644
--- a/html/eap/core/src/jsTest/kotlin/elements/ElementsTests.kt
+++ b/html/eap/core/src/jsTest/kotlin/elements/ElementsTests.kt
@@ -440,6 +440,7 @@ class ElementsTests {
override fun TagElement(
elementBuilder: ElementBuilder,
applyAttrs: (AttrsScope.() -> Unit)?,
+ rawTextContent: RawTextContent?,
content: (@Composable ElementScope.() -> Unit)?,
) {
val tagName = elementBuilder.tagName
@@ -450,7 +451,7 @@ class ElementsTests {
else -> tagName
}
DefaultComposeHtmlContext.TagElement(
- ElementBuilder.createBuilder(replacementTag), applyAttrs, content
+ ElementBuilder.createBuilder(replacementTag), applyAttrs, rawTextContent, content
)
}
}
diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt
index e1b4741a5f..ad58596f3b 100644
--- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt
+++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt
@@ -18,6 +18,7 @@ import java.io.Writer
* intact. Elements requiring content validation are emitted only after validation succeeds.
* A write can exceed [chunkSize]. No empty writes are made.
*
+ * Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation.
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
* See [composeHtmlToStream] for validation and failure handling.
*
diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt
index e8f123460e..cf09c248d5 100644
--- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt
+++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt
@@ -19,6 +19,7 @@ import java.io.Writer
* consume each write before returning because the character array is reused. The writer is not flushed
* or closed. Output already written cannot be retracted if rendering, validation or writing throws.
*
+ * Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation.
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
* See [composeHtmlToStream] for validation and failure handling.
*
@@ -43,6 +44,7 @@ fun renderHydratedDocumentToStream(
/**
* Streams a complete document with fresh storage.
*
+ * Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation.
* Calls are thread-safe. Synchronize shared mutable data accessed by [content].
* See [composeHtmlToStream] for validation and failure handling.
*/
diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Base.jvm.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Base.jvm.kt
index 0780572b13..58af1156a3 100644
--- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Base.jvm.kt
+++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Base.jvm.kt
@@ -28,6 +28,7 @@ internal actual val DefaultComposeHtmlContext: ComposeHtmlContext = object : Com
override fun TagElement(
elementBuilder: ElementBuilder,
applyAttrs: (AttrsScope.() -> Unit)?,
+ rawTextContent: RawTextContent?,
content: (@Composable ElementScope.() -> Unit)?,
) {
error("HTML rendering implementation is not provided")
diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Elements.jvm.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Elements.jvm.kt
index 9752688fcb..9cae7c0c04 100644
--- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Elements.jvm.kt
+++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Elements.jvm.kt
@@ -17,8 +17,10 @@ internal actual val platformElementBuildersCache: Map>
internal actual fun createPlatformElementBuilder(
tagName: String
): ElementBuilder = htmlBuildersCache.getOrPut(tagName) {
- object : ElementBuilder {
+ object : ElementBuilder, HtmlTagMetadata {
override val tagName: String = tagName
+ override val namespace: String = HtmlNamespace
+ override val serializationFlags = htmlStringTagFlags(tagName, namespace)
override fun create(): Element = throw UnsupportedOperationException(
"DOM element creation for <$tagName> is not available on JVM"
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
new file mode 100644
index 0000000000..53916a2840
--- /dev/null
+++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.jvm.kt
@@ -0,0 +1,24 @@
+/*
+ * 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:Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
+
+package org.jetbrains.compose.web.dom
+
+import org.jetbrains.compose.web.internal.unsafeCast
+
+internal actual fun canAppendUnescapedHtmlString(value: String, attribute: Boolean): Boolean {
+ if (value.length < 32) return false
+ // Use JVM String searches to skip Kotlin's search wrappers.
+ val text = value.unsafeCast()
+ return text.indexOf('&'.code) < 0 && text.indexOf('<'.code) < 0 && text.indexOf('>'.code) < 0 &&
+ text.indexOf('\r'.code) < 0 && text.indexOf('\u0000'.code) < 0 &&
+ (!attribute || (text.indexOf('"'.code) < 0 && text.indexOf('\n'.code) < 0 && text.indexOf('\t'.code) < 0))
+}
+
+internal actual fun StringBuilder.appendHtmlRun(value: String, start: Int, end: Int) {
+ // JDK 21 bulk-copies String ranges; avoid constructing a temporary substring.
+ append(value, start, end)
+}
diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/ComposeHtmlConcurrencyTest.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/ComposeHtmlConcurrencyTest.kt
index 1d4f6890b0..1b35d00f10 100644
--- a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/ComposeHtmlConcurrencyTest.kt
+++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/ComposeHtmlConcurrencyTest.kt
@@ -5,6 +5,7 @@
package org.jetbrains.compose.web
+import org.jetbrains.compose.web.dom.Br
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -12,6 +13,7 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.runtime.snapshots.Snapshot
import java.io.StringWriter
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
@@ -36,19 +38,19 @@ import kotlin.test.assertNotNull
class ComposeHtmlConcurrencyTest {
@Test
- fun concurrentCallsWithTheSameKeyHaveIndependentStorage() {
+ fun concurrentCallsWithTheSameKeyHaveIndependentStorageInCallerSnapshots() {
assertIndependentRenders { "concurrent-shared-key" }
}
@Test
- fun concurrentCallsWithDifferentKeysHaveIndependentStorage() {
+ fun concurrentCallsWithDifferentKeysHaveIndependentStorageInCallerSnapshots() {
// Equal hashes also exercise concurrent insertion into the renderer cache.
val keys = listOf("AaAa", "AaBB", "BBAa", "BBBB", "AaAaAa", "BBAaAa")
assertIndependentRenders { "concurrent-${keys[it]}" }
}
@Test
- fun concurrentUnkeyedCallsHaveIndependentStorage() {
+ fun concurrentUnkeyedCallsHaveIndependentStorageInCallerSnapshots() {
assertIndependentRenders { null }
}
@@ -91,7 +93,9 @@ class ComposeHtmlConcurrencyTest {
val child = if (iteration % 2 == 0) "$request" else ""
assertEquals(
"$request${boundary}<&>😀$child
",
- render((worker + iteration) % 3, keyForWorker(worker), hydratable, content),
+ inIsolatedSnapshot {
+ render((worker + iteration) % 3, keyForWorker(worker), hydratable, content)
+ },
)
assertEquals(listOf("enter $request", "side $request", "dispose $request"), effects)
assertEquals("outside", state.value)
@@ -101,7 +105,7 @@ class ComposeHtmlConcurrencyTest {
}
@Test
- fun concurrentDocumentsShareAKeyAcrossStringStreamAndWriterWithIndependentState() {
+ fun concurrentDocumentsShareAKeyAcrossStringStreamAndWriterInCallerSnapshots() {
val key = "concurrent-documents"
val state = mutableStateOf("outside")
val overlap = CyclicBarrier(6)
@@ -125,20 +129,23 @@ class ComposeHtmlConcurrencyTest {
}
}
}
- val actual = when ((worker + iteration) % 3) {
- 0 -> renderHydratedDocument(strict, key, content)
- 1 -> buildString {
- renderHydratedDocumentToStream({ append(it) }, 1, strict, key, content)
+ val actual = inIsolatedSnapshot {
+ when ((worker + iteration) % 3) {
+ 0 -> renderHydratedDocument(strict, key, content)
+ 1 -> buildString {
+ renderHydratedDocumentToStream({ append(it) }, 1, strict, key, content)
+ }
+
+ else -> StringWriter().also {
+ renderHydratedDocumentToStream(it, 1, strict, key, content)
+ }.toString()
}
- else -> StringWriter().also {
- renderHydratedDocumentToStream(it, 1, strict, key, content)
- }.toString()
}
val validation = if (strict) " data-compose-hydration-validation=\"on\"" else ""
assertEquals(
"$request
" +
- "",
+ "