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 f44c60d895..361d9cb23f 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 @@ -5,6 +5,7 @@ package org.jetbrains.compose.web +import androidx.compose.runtime.AbstractApplier import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ControlledComposition @@ -12,17 +13,15 @@ import androidx.compose.runtime.Recomposer import androidx.compose.runtime.ReusableComposition import androidx.compose.runtime.snapshots.Snapshot import kotlinx.coroutines.Dispatchers +import org.jetbrains.compose.web.dom.AttrsBuilderPool +import org.jetbrains.compose.web.dom.HtmlStringWriterContext import org.jetbrains.compose.web.dom.LocalComposeHtmlContext -import org.jetbrains.compose.web.dom.StringComposeHtmlContext -import org.jetbrains.compose.web.dom.StringHtmlApplier -import org.jetbrains.compose.web.dom.StringHtmlElementNode -import org.jetbrains.compose.web.dom.StringHtmlNodeWrapper /** * Composes [content] once into an HTML string without creating browser DOM nodes. - * With no [key], the backing composition is disposed after the initial HTML is serialized. - * With a key, the root, composition, and recomposer are retained for later calls with that key. - * Keyed calls are not thread-safe, even with different keys. Callers must synchronize them. + * With no [key], the backing composition is disposed after the HTML has been written. + * With a key, the composition and attribute-builder storage are retained for later calls with + * that key. Keyed calls are not thread-safe, even with different keys. * Snapshot state changes made while rendering are discarded afterwards. * Coroutine effects such as `LaunchedEffect` do not run. `SideEffect` and * `DisposableEffect` still execute. @@ -38,8 +37,7 @@ import org.jetbrains.compose.web.dom.StringHtmlNodeWrapper * * @param hydratable whether to emit text-boundary markers required to hydrate adjacent `Text` * nodes. Set to `false` when the output will not be hydrated. - * @param key when non-null, reuses a renderer across calls. Each render clears remembered - * state and effects but keeps matching HTML nodes. Keys and trees persist until process exit. + * @param key when non-null, reuses a composition across calls. * @param validateStrictly overrides the default strict-validation setting for this render. * @throws IllegalArgumentException if a raw-text element contains unsafe text or element children, * serialized `noscript` contents contain a `` end tag, or ordinary text, RCDATA, @@ -52,34 +50,25 @@ fun composeHtmlToString( validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, ): String { - val contentWithValidation: @Composable () -> Unit = { - CompositionLocalProvider( - LocalHtmlValidationMode provides if (validateStrictly) { - HtmlValidationMode.Strict - } else { - HtmlValidationMode.Fast - } - ) { content() } + val validatedContent: @Composable () -> Unit = { + CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) { + content() + } } return if (key == null) { - composeHtmlTree(contentWithValidation) { tree -> tree.toHtmlString(hydratable) } + composeHtmlString(hydratable = hydratable, content = validatedContent) } else { - composeReusableHtmlTree(key, contentWithValidation) { tree -> tree.toHtmlString(hydratable) } + composeReusableHtmlString(key, hydratable, validatedContent) } } private val reusableHtmlCompositions = mutableMapOf() private class ReusableHtmlComposition { - val root = StringHtmlElementNode.root() - val recomposer = Recomposer(Dispatchers.Default).apply { - // Keep coroutine effects disabled for every render, as in the unkeyed path. - cancel() - } - val composition = ReusableComposition( - applier = StringHtmlApplier(StringHtmlNodeWrapper(root)), - parent = recomposer, - ) + val recomposer = cancelledRecomposer() + val composition = ReusableComposition(UnitApplier(), recomposer) + val attrsBuilders = AttrsBuilderPool() + val output = StringBuilder() var content: (@Composable () -> Unit)? = null var rendering = false @@ -89,15 +78,16 @@ private class ReusableHtmlComposition { } } -/** Renders with a keyed composition that remains available for later calls. */ -internal fun composeReusableHtmlTree( +private fun composeReusableHtmlString( key: String, + hydratable: Boolean, content: @Composable () -> Unit, - readTree: (StringHtmlElementNode) -> T, -): T { +): String { val renderer = reusableHtmlCompositions.getOrPut(key, ::ReusableHtmlComposition) check(!renderer.rendering) { "composeHtmlToString key \"$key\" is already rendering" } + renderer.output.setLength(0) // empty string output before next render + val context = HtmlStringWriterContext(hydratable, renderer.attrsBuilders, renderer.output) val snapshot = Snapshot.takeMutableSnapshot() renderer.rendering = true renderer.content = content @@ -105,13 +95,11 @@ internal fun composeReusableHtmlTree( return snapshot.enter { try { renderer.composition.setContentWithReuse { - CompositionLocalProvider( - LocalComposeHtmlContext provides StringComposeHtmlContext - ) { + CompositionLocalProvider(LocalComposeHtmlContext provides context) { checkNotNull(renderer.content).invoke() } } - readTree(renderer.root) + context.finish(requireHtmlDocumentRoot = false) } finally { renderer.composition.deactivate() } @@ -127,34 +115,27 @@ internal fun composeReusableHtmlTree( } } -/** Renders with a fresh composition that is disposed after this call. */ -internal fun composeHtmlTree( +/** Runs a fresh composition while the context writes markup directly to its output buffer. */ +internal fun composeHtmlString( + hydratable: Boolean = true, + requireHtmlDocumentRoot: Boolean = false, content: @Composable () -> Unit, - readTree: (StringHtmlElementNode) -> T, -): T { - val root = StringHtmlElementNode.root() +): String { + val context = HtmlStringWriterContext(hydratable) val snapshot = Snapshot.takeMutableSnapshot() return try { snapshot.enter { - val recomposer = Recomposer(Dispatchers.Default).apply { - // Render the initial composition without starting coroutine effects. - cancel() - } - val composition = ControlledComposition( - applier = StringHtmlApplier(StringHtmlNodeWrapper(root)), - parent = recomposer, - ) + val recomposer = cancelledRecomposer() + val composition = ControlledComposition(applier = UnitApplier(), parent = recomposer) try { composition.setContent { - CompositionLocalProvider( - LocalComposeHtmlContext provides StringComposeHtmlContext - ) { + CompositionLocalProvider(LocalComposeHtmlContext provides context) { content() } } - readTree(root) + context.finish(requireHtmlDocumentRoot) } finally { composition.dispose() recomposer.close() @@ -164,3 +145,15 @@ internal fun composeHtmlTree( snapshot.dispose() } } + +/** Composes the initial content without starting coroutine effects. */ +private fun cancelledRecomposer() = Recomposer(Dispatchers.Default).apply { cancel() } + +/** [HtmlStringWriterContext] emits no Compose nodes, but Composition still requires an applier. */ +private class UnitApplier : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) = Unit + override fun insertBottomUp(index: Int, instance: Unit) = Unit + override fun remove(index: Int, count: Int) = Unit + override fun move(from: Int, to: Int, count: Int) = Unit + override fun onClear() = Unit +} diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlValidationMode.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlValidationMode.kt index 04fe3c541d..101e96e124 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlValidationMode.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlValidationMode.kt @@ -9,6 +9,9 @@ internal enum class HtmlValidationMode { internal val LocalHtmlValidationMode = staticCompositionLocalOf { HtmlValidationMode.Fast } +internal fun htmlValidationMode(validateStrictly: Boolean): HtmlValidationMode = + if (validateStrictly) HtmlValidationMode.Strict else HtmlValidationMode.Fast + internal fun parseHtmlValidationMode(value: String?): HtmlValidationMode = when (value?.trim()?.lowercase()) { null, "", "false", "0" -> HtmlValidationMode.Fast 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 5857aeb395..2b8bb0ab2c 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 @@ -11,9 +11,6 @@ import org.jetbrains.compose.web.dom.Div import org.jetbrains.compose.web.dom.ElementScope import org.jetbrains.compose.web.dom.InlineScript import org.jetbrains.compose.web.dom.Script -import org.jetbrains.compose.web.dom.StringHtmlElementNode -import org.jetbrains.compose.web.dom.StringHtmlNode -import org.jetbrains.compose.web.dom.isEmptyText private const val HtmlDoctype = "" @@ -34,24 +31,14 @@ private const val HtmlDoctype = "" fun renderHydratedDocument( validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, -): String = composeHtmlTree( - content = { - CompositionLocalProvider( - LocalHydratedDocumentContext provides true, - LocalHtmlValidationMode provides if (validateStrictly) { - HtmlValidationMode.Strict - } else { - HtmlValidationMode.Fast - }, - ) { - content() - } - }, - readTree = { tree -> - validateHydratedDocument(tree) - HtmlDoctype + tree.toHtmlString(hydratable = true) - }, -) +): String = HtmlDoctype + composeHtmlString(requireHtmlDocumentRoot = true) { + CompositionLocalProvider( + LocalHydratedDocumentContext provides true, + LocalHtmlValidationMode provides htmlValidationMode(validateStrictly), + ) { + content() + } +} /** * Emits one browser-hydrated region of a document and its public initial state. @@ -102,11 +89,3 @@ fun HydrationRoot( } private val LocalHydratedDocumentContext = staticCompositionLocalOf { false } - -private fun validateHydratedDocument(tree: StringHtmlElementNode) { - val documentChildren = tree.children.filterNot(StringHtmlNode::isEmptyText) - val html = documentChildren.singleOrNull() as? StringHtmlElementNode - require(html?.tagName == "html") { - "renderHydratedDocument content must produce exactly one html element" - } -} 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 19b9b0bcfa..c86fc58000 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 @@ -191,6 +191,20 @@ open class AttrsScopeBuilder( internal var allowsHydrationMismatch: Boolean = false private set + internal fun reset() { + attributesStorage?.clear() + styleStorage?.let { + it.properties.clear() + it.variables.clear() + } + propertiesStorage?.clear() + classesStorage?.clear() + hydrationProtocolAttributesStorage?.clear() + eventsListenerScopeBuilder.reset() + refEffect = null + allowsHydrationMismatch = false + } + @ComposeWebInternalApi override fun registerEventListener(listener: SyntheticEventListener<*>) { eventsListenerScopeBuilder.registerEventListener(listener) diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/EventsListenerScope.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/EventsListenerScope.kt index 0e89ac11e7..83165bc850 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/EventsListenerScope.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/EventsListenerScope.kt @@ -286,6 +286,10 @@ open class EventsListenerScopeBuilder : EventsListenerScope { // Allocate listener storage only when an element registers one. private var listeners: MutableList>? = null + internal fun reset() { + listeners?.clear() + } + override fun registerEventListener(listener: SyntheticEventListener<*>) { mutableListeners().add(listener) } diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlParserValidation.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlParserValidation.kt index 61aa0bed69..b977caba32 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlParserValidation.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlParserValidation.kt @@ -16,18 +16,19 @@ private val HtmlTableChildNames = mapOf( /** Rejects implied table wrappers and foster parenting before HTML leaves the string renderer. */ internal fun requireHtmlParserStableTableChild(parentTagName: String?, child: StringHtmlNode) { - val allowedChildren = HtmlTableChildNames[parentTagName] ?: return - val childTagName = when (child) { - is StringHtmlElementNode -> child.tagName + when (child) { + is StringHtmlElementNode -> requireHtmlParserStableTableElement(parentTagName, child.tagName) is StringHtmlTextNode -> { requireHtmlParserStableTableText(parentTagName, child.text) - return } is StringHtmlRawTextNode -> { requireHtmlParserStableTableText(parentTagName, child.content.text) - return } } +} + +internal fun requireHtmlParserStableTableElement(parentTagName: String?, childTagName: String?) { + val allowedChildren = HtmlTableChildNames[parentTagName] ?: return if (childTagName in allowedChildren) return val suggestion = when { @@ -45,7 +46,8 @@ internal fun requireHtmlParserStableTableChild(parentTagName: String?, child: St ) } -private fun requireHtmlParserStableTableText(parentTagName: String?, text: String) { +internal fun requireHtmlParserStableTableText(parentTagName: String?, text: String) { + if (parentTagName !in HtmlTableChildNames) return require(text.all { it in AsciiWhitespaceCharacters }) { "Non-whitespace text cannot be serialized directly inside <$parentTagName>; " + "the HTML parser would move it outside the table. " + 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 new file mode 100644 index 0000000000..51dce887de --- /dev/null +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.kt @@ -0,0 +1,230 @@ +/* + * 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 org.jetbrains.compose.web.HydrationProtocolAttributes + +// HTML parsing merges adjacent non-empty text nodes. This comment preserves their boundary. +internal const val HydrationTextBoundaryMarker = "c" + +private val HtmlRawTextElementNames = setOf( + "script", "style", "iframe", "xmp", "noembed", "noframes", +) + +internal fun isHtmlRawTextElement(tagName: String?, namespace: String?): Boolean = + namespace == HtmlNamespace && tagName in HtmlRawTextElementNames + +internal fun isHtmlRcdataElement(tagName: String?, namespace: String?): Boolean = + namespace == HtmlNamespace && (tagName == "title" || tagName == "textarea") + +private val HtmlVoidElementNames = setOf( + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", +) + +// HTML void elements have no content and no end tag. +internal fun isHtmlVoidElement(tagName: String, namespace: String): Boolean = + namespace == HtmlNamespace && tagName in HtmlVoidElementNames + +// HTML parsing discards the first LF in these elements. +private val HtmlLeadingNewlineElementNames = setOf("pre", "textarea", "listing") + +// Only empty boolean attribute values may be minimized without losing their value. +internal val HtmlBooleanAttributeNames = setOf( + "allowfullscreen", + "async", + "autofocus", + "autoplay", + "checked", + "controls", + "default", + "defer", + "disabled", + "formnovalidate", + "inert", + "ismap", + "itemscope", + "loop", + "multiple", + "muted", + "nomodule", + "novalidate", + "open", + "playsinline", + "readonly", + "required", + "reversed", + "selected", +) + +internal fun String.isHtmlBooleanAttributeName(): Boolean = + this in HtmlBooleanAttributeNames + +internal class StringHtmlAttributes private constructor( + val byName: Map, +) { + override fun equals(other: Any?): Boolean = + this === other || other is StringHtmlAttributes && byName == other.byName + + override fun hashCode(): Int = byName.hashCode() + + companion object { + private val Empty = StringHtmlAttributes(emptyMap()) + + fun from( + attributes: Map, + namespace: String, + validate: Boolean, + hydrationProtocolAttributes: Set, + classAttributeValue: String? = null, + styleAttributeValue: (() -> String?)? = null, + ): StringHtmlAttributes { + if (attributes.isEmpty() && classAttributeValue == null && styleAttributeValue == null) return Empty + + val serializedAttributes = linkedMapOf() + attributes.forEach { (name, value) -> + requireValidHtmlAttributeName(name) + if (validate && namespace != HtmlNamespace) { + val duplicateName = serializedAttributes.keys.firstOrNull { + it.hasSameHtmlParserAttributeName(name) + } + require(duplicateName == null) { + "Duplicate HTML attribute names \"$duplicateName\" and \"$name\"" + } + } + val protocolName = HydrationProtocolAttributes.firstOrNull { + it.equals(name, ignoreCase = true) + } + require( + protocolName == null || protocolName in hydrationProtocolAttributes + ) { + "Attribute \"$name\" is owned by the Compose hydration protocol" + } + serializedAttributes[name] = value + } + + if ("class" !in serializedAttributes && classAttributeValue != null) { + serializedAttributes["class"] = classAttributeValue + } + if ("style" !in serializedAttributes) { + styleAttributeValue?.invoke()?.let { value -> + serializedAttributes["style"] = value + } + } + + return StringHtmlAttributes(serializedAttributes) + } + } +} + +internal fun StringBuilder.appendStartTag(tagName: String, namespace: String, attributes: Map) { + append('<').append(tagName) + attributes.forEach { (name, value) -> + append(' ').append(name) + if (namespace != HtmlNamespace || value.isNotEmpty() || '-' in tagName || !name.isHtmlBooleanAttributeName()) { + append("=\"") + appendEscapedAttribute(value) + append('"') + } + } + append('>') +} + +/** Closes an element whose content was appended after [contentStart]. */ +internal fun StringBuilder.appendEndTag(tagName: String, namespace: String, contentStart: Int) { + if (namespace == HtmlNamespace) { + if (tagName == "noscript") { + // Render fallback HTML for scripting-disabled browsers, but keep it inside noscript + // when scripting is enabled and the parser treats the entire contents as raw text. + require(substring(contentStart).rawTextTagIndex("noscript", closing = true) < 0) { + "String-rendered contentStart && this[contentStart] == '\n') { + insert(contentStart, '\n') + } + } + append("') +} + +internal fun StringBuilder.appendHydrationTextBoundaryMarker() { + append("") +} + +internal fun requireValidHtmlTagName(name: String) { + require( + name.firstOrNull()?.isAsciiLetter() == true && + name.none { it in AsciiWhitespaceCharacters || it in InvalidHtmlTagNameCharacters } + ) { + "Invalid HTML tag name: \"$name\"" + } +} + +private fun requireValidHtmlAttributeName(name: String) { + require( + name.isNotEmpty() && + name.none { it.isISOControl() || it in InvalidHtmlAttributeNameCharacters } + ) { + "Invalid HTML attribute name: \"$name\"" + } +} + +internal const val AsciiWhitespaceCharacters = "\t\n\u000C\r " +private const val InvalidHtmlTagNameCharacters = "\u0000/>" +private const val InvalidHtmlAttributeNameCharacters = " \"'/>=" + +private fun Char.isAsciiLetter(): Boolean = this in 'A'..'Z' || this in 'a'..'z' + +private fun String.hasSameHtmlParserAttributeName(other: String): Boolean { + if (length != other.length) return false + for (index in indices) { + val first = this[index] + val second = other[index] + if (first == second) continue + if ( + !(first in 'A'..'Z' && second.code == first.code + ('a'.code - 'A'.code)) && + !(second in 'A'..'Z' && first.code == second.code + ('a'.code - 'A'.code)) + ) { + return false + } + } + return true +} + +private fun StringBuilder.appendEscapedAttribute(value: String) { + appendEscaped(value, attribute = true) +} + +internal fun StringBuilder.appendEscapedText(value: String) { + appendEscaped(value, attribute = false) +} + +// 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) { + var start = 0 + for (index in value.indices) { + val replacement = when (value[index]) { + '\u0000' -> throw IllegalArgumentException( + if (attribute) "HTML attribute values must not contain NUL (U+0000)" + else "HTML text must not contain NUL (U+0000)" + ) + '&' -> "&" + '<' -> "<" + '>' -> ">" + '\r' -> " " + '"' -> if (attribute) """ else null + '\n' -> if (attribute) " " else null + '\t' -> if (attribute) " " else null + else -> null + } + if (replacement != null) { + append(value, start, index) + append(replacement) + start = index + 1 + } + } + append(value, start, value.length) +} 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 new file mode 100644 index 0000000000..a71c85a524 --- /dev/null +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringWriterContext.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web.dom + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ExplicitGroupsComposable +import androidx.compose.runtime.NonRestartableComposable +import kotlinx.browser.dom.Element +import kotlinx.browser.dom.HTMLStyleElement +import org.jetbrains.compose.web.HtmlValidationMode +import org.jetbrains.compose.web.LocalHtmlValidationMode +import org.jetbrains.compose.web.attributes.AttrsScope +import org.jetbrains.compose.web.attributes.AttrsScopeBuilder +import org.jetbrains.compose.web.css.CSSRuleDeclarationList +import org.jetbrains.compose.web.internal.unsafeCast + +/** + * Writes HTML while composables execute. + * + * Each [TagElement] call writes its start tag, composes its children, then writes its end tag. + * The fields describe the currently open element. Parent state stays in local variables on the + * call stack. + */ +internal class HtmlStringWriterContext( + private val hydratable: Boolean, + private val attrsBuilders: AttrsBuilderPool? = null, + private val output: StringBuilder = StringBuilder(), +) : ComposeHtmlContext { + private val elementScope = StringElementScope() + private var finished = false + + // Attribute builders are reused by element position across keyed renders. + private var attrsBuilderIndex = 0 + + // A null tag name means that output is being written at the root. + private var currentTagName: String? = null + private var currentNamespace = HtmlNamespace + private var previousSiblingWasText = false + + // Children of a void element still compose, but must not produce output. + private var discardingVoidContent = false + + // Raw text is validated at the end tag. Raw-text elements cannot nest, so one buffer suffices. + private var collectingRawText = false + private var hasRawTextCall = false + private val rawText = StringBuilder() + + // renderHydratedDocument requires exactly one html root element. + private var rootChildCount = 0 + private var firstRootChildIsHtml = false + + override val supportsDomElementAccess: Boolean = false + + override fun elementBuilder(tagName: String): ElementBuilder = + StringElementBuilder(tagName) + + override fun elementBuilderNS( + tagName: String, + namespace: String, + ): ElementBuilder = StringElementBuilder(tagName, namespace) + + @Composable + @NonRestartableComposable + @ExplicitGroupsComposable + override fun TagElement( + elementBuilder: ElementBuilder, + applyAttrs: (AttrsScope.() -> Unit)?, + content: (@Composable ElementScope.() -> Unit)?, + ) { + checkNotFinished() + if (discardingVoidContent) { + content?.invoke(elementScope()) + return + } + + val tagName = elementBuilder.tagName + val namespace = (elementBuilder as? StringElementBuilder<*>)?.namespace ?: HtmlNamespace + val attributes = writeStartTag( + tagName = tagName, + namespace = namespace, + applyAttrs = applyAttrs, + validate = LocalHtmlValidationMode.current == HtmlValidationMode.Strict, + ) + val contentStart = output.length + + // 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) + currentTagName = tagName + currentNamespace = namespace + previousSiblingWasText = false + discardingVoidContent = isVoid + collectingRawText = isHtmlRawTextElement(tagName, namespace) + + content?.invoke(elementScope()) + + if (!isVoid) writeEndTag(tagName, namespace, attributes, contentStart) + currentTagName = parentTagName + currentNamespace = parentNamespace + previousSiblingWasText = false + discardingVoidContent = false + collectingRawText = false + } + + @Composable + @NonRestartableComposable + @ExplicitGroupsComposable + override fun RawTextElement( + tagName: String, + applyAttrs: (AttrsScope.() -> Unit)?, + content: RawTextContent, + ) { + TagElement(elementBuilder(tagName), applyAttrs) { + TextElement(content.text) + } + } + + @Composable + @NonRestartableComposable + @ExplicitGroupsComposable + override fun TextElement(value: String) { + checkNotFinished() + when { + discardingVoidContent -> Unit + collectingRawText -> { + // Even an empty text call must go through raw-text validation at the end tag. + hasRawTextCall = true + rawText.append(value) + } + value.isNotEmpty() -> writeText(value) + } + } + + @Composable + @NonRestartableComposable + @ExplicitGroupsComposable + override fun StyleElement( + applyAttrs: (AttrsScope.() -> Unit)?, + cssRules: CSSRuleDeclarationList, + ) { + RawTextElement("style", applyAttrs, prepareStyleRawTextContent(cssRules)) + } + + /** Returns the written HTML. The context rejects further output afterwards. */ + fun finish(requireHtmlDocumentRoot: Boolean): String { + check(currentTagName == null) { "String rendering finished inside <$currentTagName>" } + finished = true + if (requireHtmlDocumentRoot) { + require(rootChildCount == 1 && firstRootChildIsHtml) { + "renderHydratedDocument content must produce exactly one html element" + } + } + attrsBuilders?.trim(attrsBuilderIndex) + return output.toString() + } + + private fun writeStartTag( + tagName: String, + namespace: String, + applyAttrs: (AttrsScope.() -> Unit)?, + validate: Boolean, + ): Map { + 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) + + val attrsBuilder = attrsBuilders?.builder(attrsBuilderIndex++) ?: AttrsScopeBuilder() + applyAttrs?.invoke(attrsBuilder) + // The serialized attributes are a copy, so a pooled builder can be reset right away. + val attributes = attrsBuilder.stringAttributes(namespace, validate).byName + if (attrsBuilders != null) attrsBuilder.reset() + + output.appendStartTag(tagName, namespace, attributes) + return attributes + } + + private fun writeEndTag( + tagName: String, + namespace: String, + attributes: Map, + contentStart: Int, + ) { + if (hasRawTextCall) { + val content = RawTextContent.create(tagName, rawText.toString()) + content.validateAttributes(attributes) + output.append(content.text) + hasRawTextCall = false + rawText.setLength(0) + } + output.appendEndTag(tagName, namespace, contentStart) + } + + private fun writeText(value: String) { + if (currentTagName == null) recordRootChild(isHtml = false) + if (currentNamespace == HtmlNamespace) requireHtmlParserStableTableText(currentTagName, value) + // RCDATA decodes escaped text, but treats boundary comments as literal content. + if (hydratable && previousSiblingWasText && !isHtmlRcdataElement(currentTagName, currentNamespace)) { + output.appendHydrationTextBoundaryMarker() + } + output.appendEscapedText(value) + previousSiblingWasText = true + } + + private fun recordRootChild(isHtml: Boolean) { + if (rootChildCount++ == 0) firstRootChildIsHtml = isHtml + } + + private fun checkNotFinished() { + check(!finished) { "String rendering does not support HTML emission after composition" } + } + + private fun elementScope(): ElementScope = elementScope.unsafeCast() +} + +/** Reuses attribute builders across keyed renders, indexed by element order, without retaining their values. */ +internal class AttrsBuilderPool { + private val builders = mutableListOf>() + + fun builder(index: Int): AttrsScopeBuilder { + if (index == builders.size) builders.add(AttrsScopeBuilder()) + return builders[index].unsafeCast() + } + + /** Drops builders beyond [size] that the last render no longer needed. */ + fun trim(size: Int) { + if (size < builders.size) builders.subList(size, builders.size).clear() + } +} 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 3d9e3e675e..855122683e 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 @@ -149,7 +149,7 @@ private fun StringRawText(content: RawTextContent) { ) } -private class StringElementScope : ElementScopeBase() { +internal class StringElementScope : ElementScopeBase() { override val element: TElement get() = unavailableDomElement() @@ -175,7 +175,7 @@ private fun unavailableDomElement(): Nothing = ``` */ @OptIn(ComposeWebInternalApi::class) -private fun AttrsScopeBuilder.stringAttributes( +internal fun AttrsScopeBuilder.stringAttributes( namespace: String, validate: Boolean, ): StringHtmlAttributes { diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringHtmlNodes.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringHtmlNodes.kt index 4fb5243bd5..c5465d5a78 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringHtmlNodes.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringHtmlNodes.kt @@ -5,52 +5,6 @@ package org.jetbrains.compose.web.dom -import org.jetbrains.compose.web.HydrationProtocolAttributes - -// HTML parsing merges adjacent non-empty text nodes. This comment preserves their boundary. -internal const val HydrationTextBoundaryMarker = "c" - -private val HtmlRawTextElementNames = setOf( - "script", "style", "iframe", "xmp", "noembed", "noframes", -) - -internal fun isHtmlRawTextElement(tagName: String?, namespace: String?): Boolean = - namespace == HtmlNamespace && tagName in HtmlRawTextElementNames - -internal fun isHtmlRcdataElement(tagName: String?, namespace: String?): Boolean = - namespace == HtmlNamespace && (tagName == "title" || tagName == "textarea") - -// Only empty boolean attribute values may be minimized without losing their value. -internal val HtmlBooleanAttributeNames = setOf( - "allowfullscreen", - "async", - "autofocus", - "autoplay", - "checked", - "controls", - "default", - "defer", - "disabled", - "formnovalidate", - "inert", - "ismap", - "itemscope", - "loop", - "multiple", - "muted", - "nomodule", - "novalidate", - "open", - "playsinline", - "readonly", - "required", - "reversed", - "selected", -) - -internal fun String.isHtmlBooleanAttributeName(): Boolean = - this in HtmlBooleanAttributeNames - // in-memory equivalent of DOM node internal sealed interface StringHtmlNode { fun appendHtmlTo(builder: StringBuilder, hydratable: Boolean) @@ -59,59 +13,6 @@ internal sealed interface StringHtmlNode { internal fun StringHtmlNode.isEmptyText(): Boolean = this is StringHtmlTextNode && text.isEmpty() -internal class StringHtmlAttributes private constructor( - val byName: Map, -) { - override fun equals(other: Any?): Boolean = - this === other || other is StringHtmlAttributes && byName == other.byName - - override fun hashCode(): Int = byName.hashCode() - - companion object { - fun from( - attributes: Map, - namespace: String, - validate: Boolean, - hydrationProtocolAttributes: Set, - classAttributeValue: String? = null, - styleAttributeValue: (() -> String?)? = null, - ): StringHtmlAttributes { - val serializedAttributes = linkedMapOf() - attributes.forEach { (name, value) -> - requireValidHtmlAttributeName(name) - if (validate && namespace != HtmlNamespace) { - val duplicateName = serializedAttributes.keys.firstOrNull { - it.hasSameHtmlParserAttributeName(name) - } - require(duplicateName == null) { - "Duplicate HTML attribute names \"$duplicateName\" and \"$name\"" - } - } - val protocolName = HydrationProtocolAttributes.firstOrNull { - it.equals(name, ignoreCase = true) - } - require( - protocolName == null || protocolName in hydrationProtocolAttributes - ) { - "Attribute \"$name\" is owned by the Compose hydration protocol" - } - serializedAttributes[name] = value - } - - if ("class" !in serializedAttributes && classAttributeValue != null) { - serializedAttributes["class"] = classAttributeValue - } - if ("style" !in serializedAttributes) { - styleAttributeValue?.invoke()?.let { value -> - serializedAttributes["style"] = value - } - } - - return StringHtmlAttributes(serializedAttributes) - } - } -} - internal class StringHtmlElementNode private constructor( tagName: String?, namespace: String?, @@ -125,7 +26,7 @@ internal class StringHtmlElementNode private constructor( .also(::requireValidHtmlTagName) } internal val children: MutableList = mutableListOf() - private val attributes: MutableMap = mutableMapOf() + private var attributes: Map = emptyMap() constructor( tagName: String, @@ -144,8 +45,8 @@ internal class StringHtmlElementNode private constructor( fun updateAttributes(attributes: StringHtmlAttributes) { requireElementNamespace() - this.attributes.clear() - this.attributes.putAll(attributes.byName) + // StringHtmlAttributes owns a validated copy; it is immutable after construction. + this.attributes = attributes.byName } fun hasAttribute(name: String): Boolean = attributes.containsKey(name) @@ -164,19 +65,8 @@ internal class StringHtmlElementNode private constructor( } val namespace = requireElementNamespace() - builder.append('<').append(tagName) - attributes.forEach { (name, value) -> - builder.append(' ').append(name) - if (namespace != HtmlNamespace || value.isNotEmpty() || '-' in tagName || !name.isHtmlBooleanAttributeName()) { - builder.append("=\"") - builder.appendEscapedAttribute(value) - builder.append('"') - } - } - builder.append('>') - - // HTML void elements have neither content nor an end tag. - if (namespace == HtmlNamespace && tagName in VoidElementNames) return + builder.appendStartTag(tagName, namespace, attributes) + if (isHtmlVoidElement(tagName, namespace)) return val contentStart = builder.length // The parent determines text serialization: script/style and other raw-text elements @@ -199,39 +89,20 @@ internal class StringHtmlElementNode private constructor( // RCDATA decodes escaped text, but treats boundary comments as literal content. appendChildrenHtmlTo(builder, hydratable && !isHtmlRcdataElement(tagName, namespace)) } - if (namespace == HtmlNamespace && tagName == "noscript") { - // Render fallback HTML for scripting-disabled browsers, but keep it inside noscript - // when scripting is enabled and the parser treats the entire contents as raw text. - require(builder.substring(contentStart).rawTextTagIndex("noscript", closing = true) < 0) { - "String-rendered contentStart && builder[contentStart] == '\n' - ) { - builder.insert(contentStart, '\n') - } - builder.append("') + builder.appendEndTag(tagName, namespace, contentStart) } private fun appendChildrenHtmlTo(builder: StringBuilder, hydratable: Boolean) { - val rendered = children.filterNot(StringHtmlNode::isEmptyText) val validateTableChildren = namespace == HtmlNamespace - // Appends boundary marker for hydration between two text nodes - rendered.forEachIndexed { index, child -> - if (validateTableChildren) { - requireHtmlParserStableTableChild(tagName, child) - } + var previousWasText = false + for (index in children.indices) { + val child = children[index] + if (child.isEmptyText()) continue + if (validateTableChildren) requireHtmlParserStableTableChild(tagName, child) + val isText = child is StringHtmlTextNode + if (hydratable && previousWasText && isText) builder.appendHydrationTextBoundaryMarker() child.appendHtmlTo(builder, hydratable) - if ( - hydratable && - child is StringHtmlTextNode && - rendered.getOrNull(index + 1) is StringHtmlTextNode - ) { - builder.appendHydrationTextBoundaryMarker() - } + previousWasText = isText } } @@ -239,23 +110,6 @@ internal class StringHtmlElementNode private constructor( checkNotNull(namespace) { "The string-rendering root has no element namespace" } companion object { - private val VoidElementNames = setOf( - "area", - "base", - "br", - "col", - "embed", - "hr", - "img", - "input", - "link", - "meta", - "param", - "source", - "track", - "wbr", - ) - fun root(): StringHtmlElementNode = StringHtmlElementNode( tagName = null, namespace = null, @@ -283,76 +137,3 @@ internal class StringHtmlRawTextNode( builder.append(content.text) } } - -private fun StringBuilder.appendHydrationTextBoundaryMarker() { - append("") -} - -private fun requireValidHtmlTagName(name: String) { - require( - name.firstOrNull()?.isAsciiLetter() == true && - name.none { it in AsciiWhitespaceCharacters || it in InvalidHtmlTagNameCharacters } - ) { - "Invalid HTML tag name: \"$name\"" - } -} - -private fun requireValidHtmlAttributeName(name: String) { - require( - name.isNotEmpty() && - name.none { it.isISOControl() || it in InvalidHtmlAttributeNameCharacters } - ) { - "Invalid HTML attribute name: \"$name\"" - } -} - -internal const val AsciiWhitespaceCharacters = "\t\n\u000C\r " -private const val InvalidHtmlTagNameCharacters = "\u0000/>" -private const val InvalidHtmlAttributeNameCharacters = " \"'/>=" - -private fun Char.isAsciiLetter(): Boolean = this in 'A'..'Z' || this in 'a'..'z' - -private fun String.hasSameHtmlParserAttributeName(other: String): Boolean { - if (length != other.length) return false - for (index in indices) { - val first = this[index] - val second = other[index] - if (first == second) continue - if ( - !(first in 'A'..'Z' && second.code == first.code + ('a'.code - 'A'.code)) && - !(second in 'A'..'Z' && first.code == second.code + ('a'.code - 'A'.code)) - ) { - return false - } - } - return true -} - -private fun StringBuilder.appendEscapedAttribute(value: String) { - require('\u0000' !in value) { "HTML attribute values must not contain NUL (U+0000)" } - value.forEach { character -> - when (character) { - '&' -> append("&") - '"' -> append(""") - '<' -> append("<") - '>' -> append(">") - '\n' -> append(" ") - '\r' -> append(" ") - '\t' -> append(" ") - else -> append(character) - } - } -} - -private fun StringBuilder.appendEscapedText(value: String) { - require('\u0000' !in value) { "HTML text must not contain NUL (U+0000)" } - value.forEach { character -> - when (character) { - '&' -> append("&") - '<' -> append("<") - '>' -> append(">") - '\r' -> append(" ") - else -> append(character) - } - } -} 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 0d2f2d687c..138557e5f8 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 @@ -15,7 +15,6 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.jetbrains.compose.web.composeHtmlToString -import org.jetbrains.compose.web.composeReusableHtmlTree import org.jetbrains.compose.web.css.Color import org.jetbrains.compose.web.css.CSSUnitValue import org.jetbrains.compose.web.css.DisplayStyle @@ -36,29 +35,24 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertFailsWith -import kotlin.test.assertNotSame -import kotlin.test.assertSame class ComposeHtmlToStringTest { @Test - fun keyedRendersReuseMatchingHtmlNodes() { - fun render(value: String) = composeReusableHtmlTree( - key = "keyed-node-identity", - content = { Div { Text(value) } }, - readTree = { root -> root.children.single() as StringHtmlElementNode }, - ) - - val first = render("first") - val second = render("second") - val otherKey = composeReusableHtmlTree( - key = "another-keyed-node-identity", - content = { Div { Text("other") } }, - readTree = { root -> root.children.single() as StringHtmlElementNode }, - ) + fun keyedRendersClearAttributeValuesBetweenRequests() { + fun render(decorated: Boolean) = composeHtmlToString(key = "keyed-attribute-reset") { + Div(attrs = { + if (decorated) { + id("first") + classes("one") + style { color(Color.red) } + allowHydrationMismatch() + } + }) {} + } - assertSame(first, second) - assertNotSame(first, otherKey) - assertEquals("
second
", second.toHtmlString()) + assertEquals("
", render(true)) + assertEquals("
", render(false)) + assertEquals("
", render(true)) } @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 new file mode 100644 index 0000000000..25a127db21 --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt @@ -0,0 +1,175 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ +@file:OptIn(org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi::class) + +package org.jetbrains.compose.web.dom + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.currentCompositionLocalContext +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.browser.dom.Element +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.css.color +import org.jetbrains.compose.web.css.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class DirectStringRenderingTest { + @Test + fun keyedRendersMatchFreshRendersAcrossChangingRequests() { + for (strict in listOf(false, true)) { + for (hydratable in listOf(false, true)) { + for (request in listOf(0, 1, 2, 0, 3, 1)) { + val content: @Composable () -> Unit = { + Div(attrs = { + id("request-$request") + attr("data-text", "a\"&<>'\n\t\ré😀") + if (request % 2 == 0) { + classes("one", "two") + style { color(Color.red) } + } + }) { + for (item in (0..request).reversed()) key(item) { + TagElement(if (item % 2 == 0) "span" else "strong", null) { + Text("$item &<>\ré😀") + Text("") + Text("tail") + } + } + TagElement("textarea", null) { Text("\nfirst"); Text("second") } + TagElement("script", null) { Text("const x = '<&';") } + TagElement("noscript", null) { Span { Text("fallback") } } + TagElementNS("svg", "http://www.w3.org/2000/svg", { attr("viewBox", "0 0 1 1") }, null) + } + } + val expected = composeHtmlToString(hydratable = hydratable, validateStrictly = strict, content = content) + assertEquals(expected, composeHtmlToString(hydratable, "direct-changing", strict, content)) + } + } + } + } + + @Test + fun sameContentLambdaClearsAttributesAndPreviousOutput() { + var value = "" + var decorated = true + val content: @Composable () -> Unit = { + Div(attrs = if (decorated) { { id("decorated") } } else null) { Text(value) } + } + for ((index, text) in listOf("Unicode é😀 &<>".repeat(10_000), "short", "", "again").withIndex()) { + value = text + decorated = index % 2 == 0 + assertEquals( + composeHtmlToString(content = content), + composeHtmlToString(key = "direct-same-lambda", content = content), + ) + } + } + + @Test + fun preservesLocalsSnapshotsAndEffectLifecycle() { + val local = staticCompositionLocalOf { "default" } + val state = mutableStateOf("outside") + var remembered = 0 + var launched = false + val effects = mutableListOf() + repeat(3) { request -> + val html = composeHtmlToString(key = "direct-effects") { + state.value = "$request" + val value = remember { ++remembered } + CompositionLocalProvider(local provides "local-$request") { + Div { + Text("${local.current}:${state.value}:$value") + DisposableEffect(Unit) { + effects.add("enter $request") + onDispose { effects.add("dispose $request") } + } + SideEffect { effects.add("side $request") } + LaunchedEffect(Unit) { launched = true } + } + } + } + assertEquals("
local-$request:$request:${request + 1}
", html) + assertEquals("outside", state.value) + } + assertEquals((0..2).flatMap { listOf("enter $it", "side $it", "dispose $it") }, effects) + assertFalse(launched) + } + + @Test + fun movableContentHasTheExistingOneShotRendererLimitation() { + val movable = movableContentOf { Span { Text("moved") } } + val content: @Composable () -> Unit = { Div { movable() }; P { Text("after") } } + // The cancelled SSR recomposer has never inserted deferred movable content. + assertEquals("

after

", composeHtmlToString(content = content)) + assertEquals("

after

", composeHtmlToString(key = "movable", content = content)) + } + + @Test + fun rejectsInvalidHtmlAndRecoversAfterFailure() { + val invalid: List<@Composable () -> Unit> = listOf( + { Div { Text("bad\u0000text") } }, + { 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) } + assertFailsWith { composeHtmlToString(key = "direct-failure", content = content) } + assertEquals("
ok
", composeHtmlToString(key = "direct-failure") { Div { Text("ok") } }) + } + } + + @Test + fun capturedCompositionLocalsAndCompositionContextsRemainSupported() { + val language = staticCompositionLocalOf { "en" } + val content: @Composable () -> Unit = { + CompositionLocalProvider(language provides "de") { + val captured = currentCompositionLocalContext + rememberCompositionContext() + CompositionLocalProvider(language provides "fr") { + CompositionLocalProvider(captured) { + P { Text(language.current) } + } + } + } + } + assertEquals("

de

", composeHtmlToString(content = content)) + repeat(2) { + assertEquals("

de

", composeHtmlToString(key = "captured-locals", content = content)) + } + } + + @Test + fun nestedRenderRestoresContextAndSameKeyReentryIsRejected() { + assertEquals("
outer<span>inner</span>tail
", + composeHtmlToString(key = "outer") { + Div { + Text("outer") + Text(composeHtmlToString(key = "inner") { Span { Text("inner") } }) + Text("tail") + } + }) + assertFailsWith { + composeHtmlToString(key = "reentrant") { + composeHtmlToString(key = "reentrant") { Text("bad") } + } + } + assertEquals("ok", composeHtmlToString(key = "reentrant") { Text("ok") }) + } + +}