diff --git a/html/eap/README.md b/html/eap/README.md index 3b5af9c260..18bd6deba3 100644 --- a/html/eap/README.md +++ b/html/eap/README.md @@ -27,7 +27,8 @@ with strict validation. Browser hydration follows the server's mode. Direct call | `allowHydrationMismatch()` | Applies client values | Applies client values | | Structure differs | Renders on the client | Renders on the client | -Strict mode also checks class tokens and foreign-attribute collisions. +Strict mode also checks tag and attribute names, class tokens, +foreign-attribute collisions and table-element nesting. ## Published artifacts diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt index 67184dff4b..7529b819fd 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable * calls stay intact, as do elements whose contents require validation. A chunk can exceed [chunkSize], * and no empty chunks are sent. * + * 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]. * * [sink] runs synchronously while rendering and may block. Output already sent cannot be retracted 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 11a8d7789e..9c4f89e239 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 @@ -10,7 +10,6 @@ package org.jetbrains.compose.web import androidx.compose.runtime.Composable import androidx.compose.runtime.Composer import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.snapshots.Snapshot import org.jetbrains.compose.web.dom.AttrsBuilderPool import org.jetbrains.compose.web.dom.HtmlStringWriterContext import org.jetbrains.compose.web.dom.LocalComposeHtmlContext @@ -18,8 +17,8 @@ import org.jetbrains.compose.web.dom.LocalComposeHtmlContext /** * Composes [content] once into an HTML string without creating browser DOM nodes. * - * Snapshot state changes are discarded after rendering. Coroutine effects such as - * `LaunchedEffect` do not run. `SideEffect` and `DisposableEffect` still execute. + * Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation. + * Coroutine effects such as `LaunchedEffect` do not run. `SideEffect` and `DisposableEffect` still execute. * * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. * See [composeHtmlToStream] for validation and failure handling. @@ -63,7 +62,7 @@ internal fun composeHtmlString( val pool = getHtmlRendererPool(key) val renderer = pool?.borrow() ?: HtmlRenderer() - // Return storage to the pool only after rendering, effect cleanup and snapshot disposal succeed. + // Return storage to the pool only after rendering and effect cleanup succeed. val result = renderer.render(hydratable, requireHtmlDocumentRoot, chunkSink, chunkSize, prefix, content) pool?.recycle(renderer) return result @@ -90,39 +89,32 @@ internal class HtmlRenderer { prefix: String, content: @Composable () -> Unit, ): String { - val snapshot = Snapshot.takeMutableSnapshot() try { output.append(prefix) val context = HtmlStringWriterContext(hydratable, attrsBuilders, output, chunkSink, chunkSize) - return snapshot.enter { - var failure: Throwable? = null - try { - val wrapped: @Composable () -> Unit = { - CompositionLocalProvider(LocalComposeHtmlContext provides context) { content() } - } - @Suppress("UNCHECKED_CAST") - (wrapped as (Composer, Int) -> Unit)(composer, 1) - composer.applyEffects() - context.finish(requireHtmlDocumentRoot).also { - if (isOversized(output.capacity(), context.peakBufferSize)) { - if (chunkSink == null) output.trimToSize() - else output = StringBuilder(context.peakBufferSize) - } + var failure: Throwable? = null + try { + val wrapped: @Composable () -> Unit = { + CompositionLocalProvider(LocalComposeHtmlContext provides context) { content() } + } + @Suppress("UNCHECKED_CAST") + (wrapped as (Composer, Int) -> Unit)(composer, 1) + composer.applyEffects() + return context.finish(requireHtmlDocumentRoot).also { + if (isOversized(output.capacity(), context.peakBufferSize)) { + if (chunkSink == null) output.trimToSize() + else output = StringBuilder(context.peakBufferSize) } - } catch (cause: Throwable) { - failure = cause - throw cause - } finally { - composer.dispose(failure) } - } - } finally { - try { - snapshot.dispose() + } catch (cause: Throwable) { + failure = cause + throw cause } finally { - // Clear the output before reuse. The underlying buffer is not erased. - output.setLength(0) + composer.dispose(failure) } + } finally { + // Clear the output before reuse. The underlying buffer is not erased. + output.setLength(0) } } } 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 2cb1202d37..41fd0bfa7e 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 @@ -28,12 +28,11 @@ private const val HtmlDoctype = "" * Load client code that calls `hydrateRoot` after [HydrationRoot] and its state element are parsed: * place the script after them, defer an external classic script, or use a module script. * - * Snapshot state changes are discarded after rendering. - * * On the JVM or Node.js, `COMPOSE_HTML_VALIDATE_STRICTLY=true` enables strict validation. * [validateStrictly] overrides it. * Without strict validation, browser hydration trusts initial server values. * + * 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. * @@ -50,6 +49,7 @@ fun renderHydratedDocument( /** * Renders 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. */ @@ -67,6 +67,7 @@ fun renderHydratedDocument( * may exceed the target. [sink] runs synchronously, may block, and receives no empty chunks. * Emitted output cannot be retracted on failure, including document validation after composition. * + * 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. * @@ -89,6 +90,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/commonMain/kotlin/org/jetbrains/compose/web/HydrationProtocol.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydrationProtocol.kt index 5b054f1e69..146cbc0e3f 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydrationProtocol.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydrationProtocol.kt @@ -5,10 +5,11 @@ import org.jetbrains.compose.web.attributes.AttrsScope import org.jetbrains.compose.web.attributes.AttrsScopeBuilder import org.jetbrains.compose.web.attributes.ScriptType -internal const val HydrationRootAttribute = "data-compose-hydration-root" -internal const val HydrationStateAttribute = "data-compose-hydration-state" -internal const val HydrationForAttribute = "data-compose-hydration-for" -internal const val HydrationValidationAttribute = "data-compose-hydration-validation" +internal const val HydrationAttributePrefix = "data-compose-hydration-" +internal const val HydrationRootAttribute = "${HydrationAttributePrefix}root" +internal const val HydrationStateAttribute = "${HydrationAttributePrefix}state" +internal const val HydrationForAttribute = "${HydrationAttributePrefix}for" +internal const val HydrationValidationAttribute = "${HydrationAttributePrefix}validation" internal const val HydrationValidationEnabled = "on" internal const val HydrationStateFormat = "escaped-text-v1" internal val HydrationStateMimeType: String = ScriptType.TextPlain.typeStr diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/SinglePassComposer.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/SinglePassComposer.kt index 4bba2c7375..080f1674d4 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/SinglePassComposer.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/SinglePassComposer.kt @@ -13,6 +13,8 @@ import androidx.compose.runtime.tooling.CompositionData import androidx.compose.runtime.tooling.CompositionGroup import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import org.jetbrains.compose.web.dom.ComposeHtmlContext +import org.jetbrains.compose.web.dom.LocalComposeHtmlContext import org.jetbrains.compose.web.internal.unsafeCast /** @@ -33,6 +35,8 @@ internal class SinglePassComposer : Composer { private var hashes = LongArray(64) private var depth = 0 private var locals = emptyCompositionLocalMap + private var htmlContext: ComposeHtmlContext? = null + private var validationMode: HtmlValidationMode? = HtmlValidationMode.Fast private val providers = ArrayList() private val observers = ArrayList() private val effects = ArrayList<() -> Unit>() @@ -70,6 +74,8 @@ internal class SinglePassComposer : Composer { depth = 0 children[0] = 0 locals = emptyCompositionLocalMap + htmlContext = null + validationMode = HtmlValidationMode.Fast providers.clear() observers.clear() effects.clear() @@ -118,13 +124,42 @@ internal class SinglePassComposer : Composer { override fun changed(value: Double) = true override val currentCompositionLocalMap: CompositionLocalMap get() = locals - override fun consume(key: CompositionLocal): T = locals.read(key) + @Suppress("UNCHECKED_CAST") + override fun consume(key: CompositionLocal): T = when { + key === LocalComposeHtmlContext && htmlContext != null -> htmlContext as T + key === LocalHtmlValidationMode && validationMode != null -> validationMode as T + else -> locals.read(key) + } + private fun refreshHtmlLocals() { + // Computed providers must remain lazy and read from the current map on every consume. + val contextHolder = locals[LocalComposeHtmlContext.unsafeCast>()] + htmlContext = if (contextHolder is StaticValueHolder<*>) locals.read(LocalComposeHtmlContext) else null + val validationHolder = locals[LocalHtmlValidationMode.unsafeCast>()] + validationMode = when (validationHolder) { + null -> HtmlValidationMode.Fast + is StaticValueHolder<*> -> locals.read(LocalHtmlValidationMode) + else -> null + } + } override fun startProviders(values: Array>) { providers.add(locals) - locals = locals.builder().apply { putAll(updateCompositionMap(values, locals)) }.build() + val parent = locals + // Merge fresh providers into the parent to avoid a temporary map. + // Check this and static-value caching against the Compose runtime when upgrading. + val builder = parent.builder() + @Suppress("UNCHECKED_CAST") + for (provided in values) { + val local = provided.compositionLocal as ProvidableCompositionLocal + if (provided.canOverride || !parent.contains(local)) { + builder[local] = local.updatedStateOf(provided as ProvidedValue, null) + } + } + locals = builder.build() + refreshHtmlLocals() } override fun endProviders() { locals = providers.removeAt(providers.lastIndex) + refreshHtmlLocals() } override fun startProvider(value: ProvidedValue<*>) = startProviders(arrayOf(value)) override fun endProvider() = endProviders() diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/AttrsScope.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/AttrsScope.kt index c86fc58000..322b305e9f 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/AttrsScope.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/AttrsScope.kt @@ -374,15 +374,18 @@ private val setClassList: (HTMLElement, Array) -> Unit = { e, classL internal fun Collection.toClassAttributeValue(validate: Boolean = true): String? { if (isEmpty()) return null - if (validate) { - val seen = mutableSetOf() - forEach { token -> - require(token.isNotEmpty()) { "Class token must not be empty" } - require(token.none { it in "\t\n\u000C\r " }) { - "Class token must not contain ASCII whitespace: \"$token\"" - } - require(seen.add(token)) { "Class token must not be repeated: \"$token\"" } + if (validate) validateClassTokens() + return joinToString(" ") +} + +internal fun Collection.validateClassTokens() { + if (isEmpty()) return + val seen = mutableSetOf() + forEach { token -> + require(token.isNotEmpty()) { "Class token must not be empty" } + require(token.none { it in "\t\n\u000C\r " }) { + "Class token must not contain ASCII whitespace: \"$token\"" } + require(seen.add(token)) { "Class token must not be repeated: \"$token\"" } } - return joinToString(" ") } 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 66721b13c5..497b0ced74 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 @@ -44,6 +44,7 @@ interface StyleScope { * ``` */ fun property(propertyName: String, value: StylePropertyValue) + /** * Adds arbitrary CSS property to the inline style of the element. By default throws an error for backward compatibility * @param propertyName - the name of css property as [per spec](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference) @@ -62,13 +63,19 @@ interface StyleScope { * }) * ``` */ - fun property(propertyName: String, value: StylePropertyValue, important: Boolean): Unit = error("!important is not supported by this implementation") + fun property(propertyName: String, value: StylePropertyValue, important: Boolean): Unit = + error("!important is not supported by this implementation") + fun variable(variableName: String, value: StylePropertyValue) fun property(propertyName: String, value: String) = property(propertyName, StylePropertyValue(value)) - fun property(propertyName: String, value: String, important: Boolean) = property(propertyName, StylePropertyValue(value), important) + fun property(propertyName: String, value: String, important: Boolean) = + property(propertyName, StylePropertyValue(value), important) + fun property(propertyName: String, value: Number) = property(propertyName, StylePropertyValue(value)) - fun property(propertyName: String, value: Number, important: Boolean) = property(propertyName, StylePropertyValue(value), important) + fun property(propertyName: String, value: Number, important: Boolean) = + property(propertyName, StylePropertyValue(value), important) + fun variable(variableName: String, value: String) = variable(variableName, StylePropertyValue(value)) fun variable(variableName: String, value: Number) = variable(variableName, StylePropertyValue(value)) @@ -151,6 +158,7 @@ fun variable() = interface StyleHolder { @ComposeWebInternalApi val properties: StylePropertyList + @ComposeWebInternalApi val variables: StylePropertyList } @@ -183,7 +191,7 @@ open class StyleScopeBuilder : StyleScope, StyleHolder { override fun equals(other: Any?): Boolean { return if (other is StyleHolder) { properties.nativeEquals(other.properties) && - variables.nativeEquals(other.variables) + variables.nativeEquals(other.variables) } else false } @@ -213,6 +221,10 @@ typealias MutableStylePropertyList = MutableList @OptIn(ComposeWebInternalApi::class) internal fun StyleHolder.toStyleAttributeValue(): String? { if (properties.isEmpty() && variables.isEmpty()) return null + val ordinary = StringBuilder() + if (ordinary.appendOrdinaryDeclarations(properties, "") && + ordinary.appendOrdinaryDeclarations(variables, "--") + ) return ordinary.toString() val declarations = properties + variables.map { it.copy(name = "--${it.name}") } val removedNames = mutableSetOf() val resetPriorityNames = mutableSetOf() @@ -225,6 +237,7 @@ internal fun StyleHolder.toStyleAttributeValue(): String? { removedNames.add(declaration.name) false } + declaration.important -> declaration.name !in resetPriorityNames else -> { resetPriorityNames.add(declaration.name) @@ -243,6 +256,18 @@ internal fun StyleHolder.toStyleAttributeValue(): String? { } } +// Convert each ordinary value once, including numeric CSS carriers. +private fun StringBuilder.appendOrdinaryDeclarations(declarations: StylePropertyList, prefix: String): Boolean { + for (declaration in declarations) { + if (declaration.important) return false + val value = declaration.value.toString() + if (value.isEmpty()) return false + if (isNotEmpty()) append("; ") + append(prefix).append(declaration.name).append(": ").append(value) + } + return true +} + internal fun StylePropertyList.nativeEquals(properties: StylePropertyList): Boolean { if (this.size != properties.size) return false 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 06353208ba..8c70bb412e 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 @@ -15,7 +15,6 @@ import kotlinx.browser.dom.Element import kotlinx.browser.dom.HTMLStyleElement import org.jetbrains.compose.web.attributes.AttrsScope import org.jetbrains.compose.web.css.CSSRuleDeclarationList -import org.jetbrains.compose.web.css.utils.serializeRules import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi internal interface ComposeHtmlContext { @@ -34,6 +33,7 @@ internal interface ComposeHtmlContext { fun TagElement( elementBuilder: ElementBuilder, applyAttrs: (AttrsScope.() -> Unit)?, + rawTextContent: RawTextContent?, content: (@Composable ElementScope.() -> Unit)?, ) @@ -75,6 +75,7 @@ fun TagElement( LocalComposeHtmlContext.current.TagElement( elementBuilder = elementBuilder, applyAttrs = applyAttrs, + rawTextContent = null, content = content, ) } @@ -99,6 +100,7 @@ fun TagElementNS( context.TagElement( elementBuilder = context.elementBuilderNS(tagName, namespace), applyAttrs = applyAttrs, + rawTextContent = null, content = content, ) } @@ -128,6 +130,7 @@ fun TagElement( context.TagElement( elementBuilder = context.elementBuilder(tagName), applyAttrs = applyAttrs, + rawTextContent = null, content = content, ) } @@ -157,7 +160,12 @@ internal fun RawTextElement( internal fun prepareStyleRawTextContent(cssRules: CSSRuleDeclarationList): RawTextContent = RawTextContent.create( tagName = "style", - content = cssRules.serializeRules().joinToString("\n"), + content = buildString { + cssRules.forEachIndexed { index, rule -> + if (index != 0) append('\n') + appendRulePresentation(rule, baseIndent = "", indent = " ", delimiter = "") + } + }, ) internal class RawTextContent private constructor( 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 74ce15ccf0..4ff582abd2 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 @@ -5,9 +5,10 @@ package org.jetbrains.compose.web.dom +import org.jetbrains.compose.web.HydrationAttributePrefix import org.jetbrains.compose.web.HydrationProtocolAttributes import org.jetbrains.compose.web.attributes.AttrsScopeBuilder -import org.jetbrains.compose.web.attributes.toClassAttributeValue +import org.jetbrains.compose.web.attributes.validateClassTokens import org.jetbrains.compose.web.css.toStyleAttributeValue // HTML parsing merges adjacent non-empty text nodes. This comment preserves their boundary. @@ -89,7 +90,7 @@ internal class StringHtmlAttributes private constructor( ): StringHtmlAttributes { if (attributes.isEmpty() && classAttributeValue == null && styleAttributeValue == null) return Empty - validateStringHtmlAttributes(attributes, namespace, validate, hydrationProtocolAttributes) + validateStringHtmlAttributes(attributes, namespace, validate, hydrationProtocolAttributes, null) val serializedAttributes = linkedMapOf().apply { putAll(attributes) } if ("class" !in serializedAttributes && classAttributeValue != null) { @@ -120,17 +121,25 @@ internal fun StringBuilder.appendStartTag( namespace: String, builder: AttrsScopeBuilder<*>, validate: Boolean, - formState: FormControlState? = null, + formState: FormControlState?, + nameCache: HtmlAttributeNameCache?, ): Boolean { // Class validation runs even when an explicit class attribute overrides the class list. - val classValue = builder.classes.toClassAttributeValue(validate) + if (validate) builder.classes.validateClassTokens() val attributes = builder.collect().withFormState(formState).ifEmpty { emptyMap() } - validateStringHtmlAttributes(attributes, namespace, validate, builder.hydrationProtocolAttributes) + validateStringHtmlAttributes(attributes, namespace, validate, builder.hydrationProtocolAttributes, nameCache) val hasScriptSource = tagName == "script" && attributes.keys.any { it.equals("src", ignoreCase = true) } append('<').append(tagName) attributes.forEach { (name, value) -> appendAttribute(tagName, namespace, name, value) } - if ("class" !in attributes && classValue != null) appendAttribute(tagName, namespace, "class", classValue) + if ("class" !in attributes && builder.classes.isNotEmpty()) { + append(" class=\"") + builder.classes.forEachIndexed { index, token -> + if (index != 0) append(' ') + appendEscapedAttribute(token) + } + append('"') + } if ("style" !in attributes) { builder.styleScopeOrNull?.toStyleAttributeValue()?.let { value -> appendAttribute(tagName, namespace, "style", value) @@ -146,9 +155,12 @@ private fun validateStringHtmlAttributes( namespace: String, validate: Boolean, hydrationProtocolAttributes: Set, + nameCache: HtmlAttributeNameCache?, ) { for (name in attributes.keys) { - requireValidHtmlAttributeName(name) + if (validate) { + if (nameCache == null) requireValidHtmlAttributeName(name) else nameCache.requireValid(name) + } if (validate && namespace != HtmlNamespace) { for (previous in attributes.keys) { if (previous == name) break @@ -157,7 +169,8 @@ private fun validateStringHtmlAttributes( } } } - val protocolName = HydrationProtocolAttributes.firstOrNull { it.equals(name, ignoreCase = true) } + val protocolName = if (name.startsWith(HydrationAttributePrefix, ignoreCase = true)) + HydrationProtocolAttributes.firstOrNull { it.equals(name, ignoreCase = true) } else null require(protocolName == null || protocolName in hydrationProtocolAttributes) { "Attribute \"$name\" is owned by the Compose hydration protocol" } @@ -194,14 +207,15 @@ internal fun StringBuilder.appendHydrationTextBoundaryMarker() { } internal fun requireValidHtmlTagName(name: String) { - require( - name.firstOrNull()?.isAsciiLetter() == true && - name.none { it in AsciiWhitespaceCharacters || it in InvalidHtmlTagNameCharacters } - ) { + require(isValidHtmlTagName(name)) { "Invalid HTML tag name: \"$name\"" } } +internal fun isValidHtmlTagName(name: String): Boolean = + name.firstOrNull()?.isAsciiLetter() == true && + name.none { it in AsciiWhitespaceCharacters || it in InvalidHtmlTagNameCharacters } + private fun requireValidHtmlAttributeName(name: String) { require( name.isNotEmpty() && @@ -244,6 +258,10 @@ internal fun StringBuilder.appendEscapedText(value: String) { // 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 (canAppendUnescapedHtmlString(value, attribute)) { + append(value) + return + } var start = 0 for (index in value.indices) { val replacement = when (value[index]) { @@ -261,10 +279,29 @@ private fun StringBuilder.appendEscaped(value: String, attribute: Boolean) { else -> null } if (replacement != null) { - append(value, start, index) + appendHtmlRun(value, start, index) append(replacement) start = index + 1 } } - append(value, start, value.length) + if (start == 0) append(value) else appendHtmlRun(value, start, value.length) } + +/** A native search fast path where the platform supports it. */ +internal expect fun canAppendUnescapedHtmlString(value: String, attribute: Boolean): Boolean + +/** Per renderer, bounded, and independent of attribute values or protocol ownership. */ +internal class HtmlAttributeNameCache { + // Keep the first 128 short names without eviction bookkeeping. + // Later names still validate on every use if dynamic names fill the cache. + private val names = HashSet() + internal val size: Int get() = names.size + fun requireValid(name: String) { + if (name in names) return + requireValidHtmlAttributeName(name) + if (name.length <= 128 && names.size < 128) names.add(name) + } +} + +/** Copies a range using the platform's string representation. */ +internal expect fun StringBuilder.appendHtmlRun(value: String, start: Int, end: Int) 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 97a08fe7fc..5fee359377 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 @@ -32,11 +32,13 @@ internal class HtmlStringWriterContext( private val chunkSize: Int = 2048, ) : ComposeHtmlContext { private val streaming = chunkSink != null + // Used to trim retained output storage. var peakBufferSize: Int = 0 private set private val elementScope = StringElementScope() private var finished = false + // These elements must keep their content in the shared buffer until their end tag is written. private var bufferedElementDepth = 0 @@ -47,6 +49,7 @@ internal class HtmlStringWriterContext( private var currentTagName: String? = null private var currentNamespace = HtmlNamespace private var previousSiblingWasText = false + // The HTML parser drops the first LF in pre, textarea, and listing. Repair it when the // first content is written, so these elements can stream without retaining their children. private var pendingLeadingNewline = false @@ -66,12 +69,12 @@ internal class HtmlStringWriterContext( override val supportsDomElementAccess: Boolean = false override fun elementBuilder(tagName: String): ElementBuilder = - StringElementBuilder(tagName) + attrsBuilders.elementBuilder(tagName, HtmlNamespace) override fun elementBuilderNS( tagName: String, namespace: String, - ): ElementBuilder = StringElementBuilder(tagName, namespace) + ): ElementBuilder = attrsBuilders.elementBuilder(tagName, namespace) @Composable @NonRestartableComposable @@ -79,6 +82,7 @@ internal class HtmlStringWriterContext( override fun TagElement( elementBuilder: ElementBuilder, applyAttrs: (AttrsScope.() -> Unit)?, + rawTextContent: RawTextContent?, content: (@Composable ElementScope.() -> Unit)?, ) { checkNotFinished() @@ -88,39 +92,50 @@ internal class HtmlStringWriterContext( } val tagName = elementBuilder.tagName - val namespace = (elementBuilder as? StringElementBuilder<*>)?.namespace ?: HtmlNamespace + 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 // Emit any completed chunk before writing this start tag. flushChunk() val bufferElement = streaming && - (isHtmlRawTextElement(tagName, namespace) || (namespace == HtmlNamespace && tagName == "noscript")) + (rawTextElement || (namespace == HtmlNamespace && tagName == "noscript")) if (bufferElement) bufferedElementDepth++ - val (hasScriptSource, contentOverride) = writeStartTag( + val startTag = writeStartTag( tagName = tagName, namespace = namespace, applyAttrs = applyAttrs, validate = LocalHtmlValidationMode.current == HtmlValidationMode.Strict, + tagNameIsValid = flags != null && 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 = isHtmlVoidElement(tagName, namespace) + val isVoid = if (flags == null) isHtmlVoidElement(tagName, namespace) else flags and VoidTagFlag != 0 currentTagName = tagName currentNamespace = namespace previousSiblingWasText = false - pendingLeadingNewline = isHtmlLeadingNewlineElement(tagName, namespace) - collectingRawText = isHtmlRawTextElement(tagName, namespace) + pendingLeadingNewline = if (flags == null) isHtmlLeadingNewlineElement(tagName, namespace) + else 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) discardingContent = isVoid || contentOverride != null + if (rawTextContent != null && !discardingContent) { + rawTextContent.validateScriptSource(startTag.hasScriptSource) + output.append(rawTextContent.text) + } content?.invoke(elementScope()) - if (!isVoid) writeEndTag(tagName, namespace, hasScriptSource, contentStart) + if (!isVoid) writeEndTag(tagName, namespace, startTag.hasScriptSource, contentStart) currentTagName = parentTagName currentNamespace = parentNamespace previousSiblingWasText = false @@ -139,9 +154,7 @@ internal class HtmlStringWriterContext( applyAttrs: (AttrsScope.() -> Unit)?, content: RawTextContent, ) { - TagElement(elementBuilder(tagName), applyAttrs) { - TextElement(content.text) - } + TagElement(elementBuilder(tagName), applyAttrs, rawTextContent = content, content = null) } @Composable @@ -156,6 +169,7 @@ internal class HtmlStringWriterContext( hasRawTextCall = true rawText.append(value) } + value.isNotEmpty() -> { writeText(value) flushChunk() @@ -197,26 +211,38 @@ internal class HtmlStringWriterContext( namespace: String, applyAttrs: (AttrsScope.() -> Unit)?, validate: Boolean, - ): Pair { - requireValidHtmlTagName(tagName) + tagNameIsValid: Boolean, + ): StartTagResult { + if (validate && !tagNameIsValid) requireValidHtmlTagName(tagName) require(!collectingRawText) { "String rendering does not support element children inside <$currentTagName>" } if (currentTagName == null) recordRootChild(isHtml = tagName == "html") - if (currentNamespace == HtmlNamespace) requireHtmlParserStableTableElement(currentTagName, tagName) + if (validate && currentNamespace == HtmlNamespace) requireHtmlParserStableTableElement(currentTagName, tagName) val builder = attrsBuilders.builder(attrsBuilderIndex++) try { applyAttrs?.invoke(builder) val formState = builder.propertyUpdatesOrEmpty.formControlState(tagName, namespace) - val hasScriptSource = output.appendStartTag(tagName, namespace, builder, validate, formState) + val hasScriptSource = + output.appendStartTag(tagName, namespace, builder, validate, formState, attrsBuilders.nameCache) pendingLeadingNewline = false // A child tag starts with '<', which needs no LF repair. - return hasScriptSource to formState?.textContent + return if (formState?.textContent == null && !hasScriptSource) StartTagResult.Ordinary + else StartTagResult(formState?.textContent, hasScriptSource) } finally { builder.reset() } } + // Reuse the ordinary result instead of creating a Pair for every tag. + // Allocation stays predictable when the JVM cannot eliminate temporary objects. + private class StartTagResult(val textContent: String?, val hasScriptSource: Boolean) { + companion object { + // Ordinary tags need neither a text override nor script-source validation. + val Ordinary = StartTagResult(null, false) + } + } + private fun writeEndTag( tagName: String, namespace: String, @@ -272,7 +298,22 @@ internal class HtmlStringWriterContext( /** Reuses attribute builders by element order without retaining their values between elements. */ internal class AttrsBuilderPool { + val nameCache = HtmlAttributeNameCache() private val builders = mutableListOf>() + // Reuse immutable metadata for SVG elements and keyed renders. Bound both the number + // and length of retained dynamic names. Only one active renderer owns this storage. + private val elementBuilders = HashMap>>() + private var elementBuilderCount = 0 + + fun elementBuilder(tagName: String, namespace: String): ElementBuilder { + elementBuilders[namespace]?.get(tagName)?.let { return it.unsafeCast() } + val builder = StringElementBuilder(tagName, namespace) + if (elementBuilderCount < 128 && tagName.length <= 128 && namespace.length <= 256) { + elementBuilders.getOrPut(namespace) { HashMap() }[tagName] = builder + elementBuilderCount++ + } + return builder + } fun builder(index: Int): AttrsScopeBuilder { if (index == builders.size) builders.add(AttrsScopeBuilder()) diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringComposeHtmlContext.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringComposeHtmlContext.kt index ee9e1f6ff1..c43ab0852a 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringComposeHtmlContext.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringComposeHtmlContext.kt @@ -38,11 +38,13 @@ internal object StringComposeHtmlContext : ComposeHtmlContext { override fun TagElement( elementBuilder: ElementBuilder, applyAttrs: (AttrsScope.() -> Unit)?, + rawTextContent: RawTextContent?, content: (@Composable ElementScope.() -> Unit)?, ) = StringTagElement( elementBuilder = elementBuilder, applyAttrs = applyAttrs, content = content, + rawText = rawTextContent, ) @Composable diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringElementBuilder.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringElementBuilder.kt index c1e3a52ebd..675992357c 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringElementBuilder.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringElementBuilder.kt @@ -9,11 +9,29 @@ import kotlinx.browser.dom.Element internal class StringElementBuilder( override val tagName: String, - val namespace: String = HtmlNamespace, -) : ElementBuilder { + override val namespace: String = HtmlNamespace, +) : ElementBuilder, HtmlTagMetadata { + override val serializationFlags = htmlStringTagFlags(tagName, namespace) + override fun create(): TElement { throw UnsupportedOperationException( "String element builder for <$tagName> cannot create a DOM element" ) } } + +internal interface HtmlTagMetadata { + val namespace: String + val serializationFlags: Int +} + +internal const val RawTextTagFlag = 1 +internal const val VoidTagFlag = 2 +internal const val LeadingNewlineTagFlag = 4 +internal const val ValidTagFlag = 8 + +internal fun htmlStringTagFlags(tagName: String, namespace: String): Int = + (if (isHtmlRawTextElement(tagName, namespace)) RawTextTagFlag else 0) or + (if (isHtmlVoidElement(tagName, namespace)) VoidTagFlag else 0) or + (if (isHtmlLeadingNewlineElement(tagName, namespace)) LeadingNewlineTagFlag else 0) or + (if (isValidHtmlTagName(tagName)) ValidTagFlag else 0) diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Style.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Style.kt index ae01fbe335..8278f17d3a 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Style.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Style.kt @@ -14,29 +14,40 @@ fun CSSRuleDeclaration.stringPresentation( indent: String = " ", delimiter: String = "\n" ): String { - val cssRuleDeclaration = this - val strings = mutableListOf() - strings.add("$baseIndent${cssRuleDeclaration.header} {") - when (cssRuleDeclaration) { + return buildString { appendRulePresentation(this@stringPresentation, baseIndent, indent, delimiter) } +} + +internal fun StringBuilder.appendRulePresentation( + rule: CSSRuleDeclaration, + baseIndent: String, + indent: String, + delimiter: String, +) { + append(baseIndent).append(rule.header).append(" {") + when (rule) { is CSSStyledRuleDeclaration -> { - cssRuleDeclaration.style.properties.forEach { (name, value, important) -> - strings.add("$baseIndent$indent$name: $value${if (important) " !important" else ""};") + for ((name, value, important) in rule.style.properties) { + append(delimiter).append(baseIndent).append(indent).append(name).append(": ").append(value) + if (important) append(" !important") + append(';') } - cssRuleDeclaration.style.variables.forEach { (name, value) -> - strings.add("$baseIndent$indent--$name: $value;") + for ((name, value) in rule.style.variables) { + append(delimiter).append(baseIndent).append(indent).append("--").append(name) + .append(": ").append(value).append(';') } } is CSSGroupingRuleDeclaration -> { - cssRuleDeclaration.rules.forEach { childRuleDeclaration -> - strings.add(childRuleDeclaration.stringPresentation(baseIndent + indent, indent, delimiter)) + for (child in rule.rules) { + append(delimiter) + appendRulePresentation(child, baseIndent + indent, indent, delimiter) } } is CSSKeyframesRuleDeclaration -> { - cssRuleDeclaration.keys.forEach { childRuleDeclaration -> - strings.add(childRuleDeclaration.stringPresentation(baseIndent + indent, indent, delimiter)) + for (child in rule.keys) { + append(delimiter) + appendRulePresentation(child, baseIndent + indent, indent, delimiter) } } } - strings.add("$baseIndent}") - return strings.joinToString(delimiter) + append(delimiter).append(baseIndent).append('}') } 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 ab5a6b8852..fd9f916876 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 @@ -53,7 +53,7 @@ class HydratedDocumentPoolTest { assertContains(rendered, "$request") assertEquals(strict, "class=\"first\"" in rendered) assertEquals(strict, HydrationValidationAttribute in rendered) - assertEquals("outside", snapshotState.value) + assertEquals(request, snapshotState.value) assertEquals(remembered, disposals) assertSame(renderer, pool.borrow()) pool.recycle(renderer) 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 0a2f5c00a3..2246250ccf 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 @@ -206,7 +206,7 @@ class HydratedDocumentStreamTest { } @Test - fun discardsSnapshotChangesAndDisposesTheCompositionEvenWhenTheSinkFails() { + fun retainsSnapshotChangesAndDisposesTheCompositionEvenWhenTheSinkFails() { for (fail in listOf(false, true)) { val sharedState = mutableStateOf("initial") var disposals = 0 @@ -224,7 +224,7 @@ class HydratedDocumentStreamTest { } if (fail) assertSame(sinkFailure, assertFailsWith { render() }) else render() - assertEquals("initial", sharedState.value) + assertEquals("rendered", sharedState.value) assertEquals(1, disposals) if (!fail) assertContains(chunks.single(), "rendered") } diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt index f8ad3aef7b..a474836dc3 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt @@ -7,6 +7,7 @@ package org.jetbrains.compose.web import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.Snapshot import kotlinx.browser.dom.Element import kotlinx.coroutines.Dispatchers import org.jetbrains.compose.web.dom.* @@ -82,7 +83,7 @@ class SinglePassRenderingTest { } @Test - fun sameLambdaFreshRememberAndSnapshotIsolation() { + fun sameLambdaFreshRememberAndStateWrites() { val state = mutableStateOf("outside") var value = "" var calls = 0 @@ -95,11 +96,49 @@ class SinglePassRenderingTest { value = next val expected = composeHtmlToString { Div { Text("$value:${i + 1}") } } assertEquals(expected, composeHtmlToString(key = "same-lambda", content = content)) - assertEquals("outside", state.value) + assertEquals(value, state.value) } assertEquals(4, calls) } + @Test + fun readOnlySnapshotRejectsWritesToExistingStateFromContentAndEffects() { + val state = mutableStateOf("before") + for (writeInEffect in listOf(false, true)) { + val snapshot = Snapshot.takeSnapshot() + try { + assertFailsWith { + snapshot.enter { + composeHtmlToString { + if (writeInEffect) SideEffect { state.value = "after" } + else state.value = "after" + } + } + } + } finally { + snapshot.dispose() + } + assertEquals("before", state.value) + } + } + + @Test + fun readOnlySnapshotRejectsWritesToStateCreatedDuringRendering() { + val snapshot = Snapshot.takeSnapshot() + try { + assertFailsWith { + snapshot.enter { + composeHtmlToString { + val state = remember { mutableStateOf("before") } + state.value = "after" + } + } + } + } finally { + snapshot.dispose() + } + } + @Test fun observersAndEffectsPreserveLifecycleOrdering() { val events = mutableListOf() @@ -398,6 +437,47 @@ class SinglePassRenderingTest { assertEquals("ok", composeHtmlToString(key = "outer") { Text("ok") }) } + @Test + fun internalValidationLocalOverridesAndRestoresInNestedProviders() { + repeat(3) { + assertFailsWith { + composeHtmlToString(key = "validation-provider-reuse", validateStrictly = false) { + CompositionLocalProvider(LocalHtmlValidationMode provides HtmlValidationMode.Strict) { + Div({ classes("bad token") }) + } + } + } + assertEquals("
ok", composeHtmlToString(key = "validation-provider-reuse", validateStrictly = false) { + CompositionLocalProvider(LocalHtmlValidationMode provides HtmlValidationMode.Strict) { Span {} } + CompositionLocalProvider(LocalHtmlValidationMode provides HtmlValidationMode.Fast) { Div({ classes("bad token") }) } + Span { Text("ok") } + }) + } + } + + @Test + fun computedValidationProvidersReadChangingValues() { + var strict = false + assertFailsWith { + composeHtmlToString { + CompositionLocalProvider(LocalHtmlValidationMode providesComputed { + if (strict) HtmlValidationMode.Strict else HtmlValidationMode.Fast + }) { + Div({ classes("allowed in fast mode") }) + strict = true + Div({ classes("rejected in strict mode") }) + } + } + } + } + + @Test + fun unusedComputedContextProvidersRemainLazy() { + assertEquals("", composeHtmlToString { + CompositionLocalProvider(LocalComposeHtmlContext providesComputed { error("Unused context") }) {} + }) + } + private enum class HashKey { First, Second } private class Observer(val name: String, val events: MutableList) : RememberObserver { @@ -405,4 +485,5 @@ class SinglePassRenderingTest { override fun onForgotten() { events.add("forget $name") } override fun onAbandoned() { events.add("abandon $name") } } + } diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToStringTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToStringTest.kt index 138557e5f8..7ab7f8a7b4 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToStringTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToStringTest.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshots.Snapshot import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -108,7 +109,7 @@ class ComposeHtmlToStringTest { } @Test - fun keyedRendersDiscardSnapshotWritesAndHonorHydrationMode() { + fun keyedRendersRetainSnapshotWritesAndHonorHydrationMode() { val state = mutableStateOf("initial") fun render(value: String, hydratable: Boolean) = composeHtmlToString(hydratable = hydratable, key = "keyed-snapshot") { @@ -118,9 +119,9 @@ class ComposeHtmlToStringTest { } assertEquals("firsttail", render("first", hydratable = false)) - assertEquals("initial", state.value) + assertEquals("first", state.value) assertEquals("secondtail", render("second", hydratable = true)) - assertEquals("initial", state.value) + assertEquals("second", state.value) } @Test @@ -181,7 +182,7 @@ class ComposeHtmlToStringTest { } @Test - fun discardsSnapshotStateChangesMadeWhileRendering() { + fun retainsSnapshotStateChangesMadeWhileRenderingAndDisposing() { val sharedState = mutableStateOf("initial") val html = composeHtmlToString { @@ -194,7 +195,56 @@ class ComposeHtmlToStringTest { } assertEquals("render", html) - assertEquals("initial", sharedState.value) + assertEquals("dispose", sharedState.value) + } + + @Test + fun callerSnapshotControlsIsolationAndIncludesEffectCleanup() { + for (renderKey in listOf(null, "caller-snapshot")) { + val state = mutableStateOf("outside") + val snapshot = Snapshot.takeMutableSnapshot() + try { + snapshot.enter { + state.value = "caller" + assertEquals("caller", composeHtmlToString(key = renderKey) { + Text(state.value) + SideEffect { state.value = "side effect" } + DisposableEffect(Unit) { + onDispose { state.value = "dispose" } + } + }) + assertEquals("dispose", state.value) + } + assertEquals("outside", state.value) + } finally { + snapshot.dispose() + } + assertEquals("outside", state.value) + } + } + + @Test + fun callerSnapshotIsNotDisposedWhenRenderingFails() { + val state = mutableStateOf("outside") + val snapshot = Snapshot.takeMutableSnapshot() + try { + snapshot.enter { + assertFailsWith { + 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", 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<", 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("safe", 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) }, + "
cell
" 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&amp;
", + 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
" + - "", + "", actual, ) assertEquals(1, disposals) @@ -356,6 +363,51 @@ class ComposeHtmlConcurrencyTest { } } + @Test + fun callerSnapshotsKeepReadsConsistentAcrossConcurrentStateChanges() { + for (mode in listOf("global", "read-only", "mutable")) { + val first = mutableStateOf("before") + val second = mutableStateOf("before") + val firstRead = CountDownLatch(1) + val updateApplied = CountDownLatch(1) + runConcurrently(2) { worker -> + if (worker == 0) { + val snapshot = when (mode) { + "read-only" -> Snapshot.takeSnapshot() + "mutable" -> Snapshot.takeMutableSnapshot() + else -> null + } + try { + val render = { + composeHtmlToString(hydratable = false) { + Text(first.value) + firstRead.countDown() + assertTrue(updateApplied.await(10, TimeUnit.SECONDS)) + Text(second.value) + } + } + assertEquals( + if (snapshot == null) "beforeafter" else "beforebefore", + if (snapshot == null) render() else snapshot.enter(render), + ) + } finally { + snapshot?.dispose() + } + } else { + assertTrue(firstRead.await(10, TimeUnit.SECONDS)) + try { + Snapshot.withMutableSnapshot { + first.value = "after" + second.value = "after" + } + } finally { + updateApplied.countDown() + } + } + } + } + } + private fun render( mode: Int, key: String?, @@ -366,11 +418,22 @@ class ComposeHtmlConcurrencyTest { 1 -> buildString { composeHtmlToStream(sink = { append(it) }, chunkSize = 1, hydratable, key, content = content) } + else -> StringWriter().also { composeHtmlToStream(it, chunkSize = 1, hydratable, key, content = content) }.toString() } + // The caller owns isolation when a render mutates shared snapshot state. + private fun inIsolatedSnapshot(action: () -> T): T { + val snapshot = Snapshot.takeMutableSnapshot() + try { + return snapshot.enter(action) + } finally { + snapshot.dispose() + } + } + private fun runConcurrently(workers: Int, action: (Int) -> Unit) { val executor = Executors.newFixedThreadPool(workers) val start = CyclicBarrier(workers) @@ -387,6 +450,32 @@ class ComposeHtmlConcurrencyTest { assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)) } } + + @Test + fun cachedTagMetadataAndNativeSearchPreserveConcurrentRequests() { + val prefix = "ordinary native-search content é😀".repeat(32) + runConcurrently(8) { worker -> + repeat(100) { iteration -> + val request = "$worker-$iteration" + val html = composeHtmlToString(key = "cached-serialization-concurrent-requests") { + Div(attrs = { + attr("data-request", request) + classes("one", "two") + }) { + Text(prefix) + Span(attrs = { attr("title", "$prefix<&") }) { Text(request) } + Br() + } + } + assertEquals( + "
$prefix" + + "$request
", + html, + ) + } + } + } + } private val LocalRequest = staticCompositionLocalOf { "outside" } 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 9f58efa989..6c4e88e458 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 @@ -478,8 +478,13 @@ private object BrowserComposeHtmlContext : ComposeHtmlContext { override fun TagElement( elementBuilder: ElementBuilder, applyAttrs: (AttrsScope.() -> Unit)?, + rawTextContent: RawTextContent?, content: (@Composable ElementScope.() -> Unit)?, ) { + if (rawTextContent != null) { + RawTextElement(elementBuilder.tagName, applyAttrs, rawTextContent) + return + } TagElementImpl( elementBuilder = elementBuilder, applyAttrs = applyAttrs, @@ -527,6 +532,7 @@ private object BrowserComposeHtmlContext : ComposeHtmlContext { TagElement( elementBuilder = elementBuilder("style"), applyAttrs = applyAttrs, + rawTextContent = null, ) { StyleSheetEffect(cssRules) { prepareStyleRawTextContent(cssRules) } } @@ -563,8 +569,13 @@ private class HydratingComposeHtmlContext( override fun TagElement( elementBuilder: ElementBuilder, applyAttrs: (AttrsScope.() -> Unit)?, + rawTextContent: RawTextContent?, content: (@Composable ElementScope.() -> Unit)?, ) { + if (rawTextContent != null) { + RawTextElement(elementBuilder.tagName, applyAttrs, rawTextContent) + return + } val hydrationAwareBuilder = if ( applier.isHydrating && elementBuilder !is HydratingElementBuilder<*> ) { 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 new file mode 100644 index 0000000000..cdd939ce1b --- /dev/null +++ b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.web.kt @@ -0,0 +1,12 @@ +/* + * 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 + +internal actual fun canAppendUnescapedHtmlString(value: String, attribute: Boolean): Boolean = false + +internal actual fun StringBuilder.appendHtmlRun(value: String, start: Int, end: Int) { + append(value, start, end) +}