and updates the parent state.
+ * ```
+ *
+ * Values are evaluated once in their original order, then escaped when written.
+ */
+@OptIn(UnsafeDuringIrConstructionAPI::class)
+internal class HtmlAttributeWriter(
+ private val context: IrPluginContext,
+ private val symbols: HtmlSymbols,
+ private val constants: ConstantEvaluator,
+ private val fields: HtmlFields,
+) {
+ private val unit = context.irBuiltIns.unitType
+ private val nullableString = context.irBuiltIns.stringType.makeNullable()
+
+ fun canEmit(plan: MixedAttributes): Boolean =
+ fields.canStore(plan.operations.filter {
+ it.kind in AttributeSlotKinds
+ }.map { " ${it.name}" } + "<${plan.tag}")
+
+ // Evaluate attributes in their original order, then write the opening tag.
+ // Returns the parent state needed to close the element.
+ fun emit(
+ plan: MixedAttributes,
+ builder: DeclarationIrBuilder,
+ writer: IrVariable,
+ validate: IrVariable,
+ ): IrExpression = builder.irBlock(resultType = symbols.finishMixedElement.owner.returnType) {
+ val state = assignSlots(plan)
+ for (operation in plan.before) +operation(operation, operation.values, state, builder)
+ for (statement in rewriteBody(plan, state, builder)) +statement
+ for (operation in plan.after) +operation(operation, operation.values, state, builder)
+ state.properties[AttributeOperationKind.FormValue]?.let { slot ->
+ +irIfThen(
+ unit, irNotEquals(irGet(slot), irNull()),
+ assign("value", listOf(irGet(slot)), state, builder)
+ )
+ }
+ state.properties[AttributeOperationKind.FormChecked]?.let { slot ->
+ +irIfThen(
+ unit, irNotEquals(irGet(slot), irNull()),
+ irIfThenElse(
+ unit,
+ irEquals(irGet(slot), irTrue()),
+ assign("checked", emptyList(), state, builder),
+ irSet(state.attributes.getValue("checked"), irNull())
+ )
+ )
+ }
+ validateClasses(state, writer, validate)
+ writeAttributes(plan, state, builder, writer)
+ writeClasses(state, builder, writer)
+ state.style?.let { slot ->
+ val outputStyle = irCallWithArgs(symbols.writeMixedStyle, irGet(writer), irGet(slot))
+ val explicit = state.attributes["style"]
+ +if (explicit == null) outputStyle else irIfThen(
+ unit, irEqualsNull(irGet(explicit)), outputStyle
+ )
+ }
+ val parent = irTemporary(
+ irCallWithArgs(
+ symbols.finishMixedElement, irGet(writer), irString(plan.tag), irString(plan.namespace),
+ irBoolean(isHtmlLeadingNewlineElement(plan.tag, plan.namespace))
+ ),
+ "htmlParent"
+ )
+ if (plan.tag == "textarea") {
+ val text = state.properties[AttributeOperationKind.TextContent]
+ val default = state.properties[AttributeOperationKind.TextDefault]
+ if (text != null || default != null) {
+ val value = irTemporary(
+ when {
+ text == null -> irGet(requireNotNull(default))
+ default == null -> irGet(text)
+ else -> irIfThenElse(nullableString, irEqualsNull(irGet(text)), irGet(default), irGet(text))
+ }, "htmlTextContent"
+ )
+ +irIfThen(
+ unit, irNotEquals(irGet(value), irNull()),
+ irCallWithArgs(
+ symbols.writeText,
+ irGet(writer),
+ irImplicitCast(irGet(value), context.irBuiltIns.stringType),
+ irGet(validate)
+ )
+ )
+ }
+ }
+ +irGet(parent)
+ }
+
+ private class Slots(
+ val names: List
,
+ val attributes: Map,
+ val order: Map,
+ val count: IrVariable?,
+ val classes: Map>,
+ val properties: Map,
+ val style: IrVariable?,
+ )
+
+ // Keep attribute values in locals. Repeated names also track their first insertion order.
+ private fun IrBlockBuilder.assignSlots(plan: MixedAttributes): Slots {
+ val names = plan.operations.filter {
+ it.kind in AttributeSlotKinds
+ }.map { it.name }.distinct()
+ val slots = names.associateWith { name ->
+ irTemporary(
+ irNull(nullableString),
+ "html_${name.replace('-', '_')}",
+ isMutable = true
+ )
+ }
+ val repeated = plan.attributes.size != names.size || plan.operations.any {
+ it.kind == AttributeOperationKind.FormValue || it.kind == AttributeOperationKind.FormChecked
+ }
+ val order = if (repeated) names.associateWith {
+ irTemporary(irInt(-1), "htmlAttributeOrder", isMutable = true)
+ } else emptyMap()
+ val count = if (repeated) irTemporary(irInt(0), "htmlAttributeCount", isMutable = true) else null
+ val classSlots = plan.classes.associateWith { operation ->
+ operation.values.map {
+ irTemporary(
+ irNull(nullableString),
+ "htmlClass",
+ isMutable = true
+ )
+ }
+ }
+ val properties = plan.operations.map { it.kind }.filter {
+ it in PropertySlotKinds
+ }.distinct().associateWith { kind ->
+ val type =
+ if (kind == AttributeOperationKind.FormChecked) context.irBuiltIns.booleanType else context.irBuiltIns.stringType
+ irTemporary(irNull(type.makeNullable()), "html${kind.name}", isMutable = true)
+ }
+ val style = if (plan.styles.isEmpty()) null else irTemporary(
+ irNull(symbols.mixedStyle.owner.returnType.makeNullable()), "htmlStyle", isMutable = true
+ )
+ return Slots(names, slots, order, count, classSlots, properties, style)
+ }
+
+ private fun assign(
+ name: String,
+ values: List,
+ state: Slots,
+ builder: DeclarationIrBuilder
+ ): IrExpression =
+ builder.irBlock(resultType = unit) {
+ val slot = state.attributes.getValue(name)
+ val value = irTemporary(values.singleOrNull() ?: irString(""), "htmlValue")
+ if (state.count != null) {
+ +irIfThen(
+ unit, irEqualsNull(irGet(slot)),
+ irBlock(resultType = unit) {
+ +irSet(state.order.getValue(name), irGet(state.count))
+ +irSet(
+ state.count,
+ irCallWithArgs(context.irBuiltIns.intPlusSymbol, irGet(state.count), irInt(1))
+ )
+ })
+ }
+ +irSet(slot, irGet(value))
+ }
+
+ private fun operation(
+ operation: AttributeOperation,
+ values: List,
+ state: Slots,
+ builder: DeclarationIrBuilder,
+ ): IrExpression {
+ val result = builder.irBlock(resultType = unit) {
+ when (operation.kind) {
+ AttributeOperationKind.Classes -> for ((slot, value) in state.classes.getValue(operation)
+ .zip(values)) +irSet(slot, value)
+
+ AttributeOperationKind.Attribute -> +assign(operation.name, values, state, builder)
+ AttributeOperationKind.Style -> {
+ val slot = requireNotNull(state.style)
+ +irSet(slot, irCallWithArgs(symbols.mixedStyle, irGet(slot), values.single()))
+ }
+
+ AttributeOperationKind.FormValue -> {
+ val value = irTemporary(values.single(), "htmlFormValue")
+ val condition = requireNotNull(operation.valueIsAttribute).deepCopyWithSymbols()
+ val attribute = (condition as? IrConst)?.value as? Boolean
+ val setAttribute = assign("value", listOf(irGet(value)), state, builder)
+ val setProperty = irSet(state.properties.getValue(AttributeOperationKind.FormValue), irGet(value))
+ +when (attribute) {
+ true -> setAttribute
+ false -> setProperty
+ null -> irIfThenElse(
+ unit,
+ condition, setAttribute, setProperty
+ )
+ }
+ }
+
+ AttributeOperationKind.Ignore -> for (value in values) +value
+ else -> +irSet(state.properties.getValue(operation.kind), values.single())
+ }
+ }
+ return operation.condition?.let { builder.irIfThen(unit, it.deepCopyWithSymbols(), result) } ?: result
+ }
+
+ // Replace attrs calls with local assignments, keeping branches and supported let bodies.
+ private fun rewriteBody(plan: MixedAttributes, state: Slots, builder: DeclarationIrBuilder): List {
+ val byCall = plan.bodyOperations.associateBy { requireNotNull(it.call) }
+ val statementExpressions = mutableSetOf()
+ fun recordStatements(statement: IrStatement) {
+ when (statement) {
+ is IrContainerExpression -> {
+ statementExpressions.add(statement)
+ statement.statements.forEach(::recordStatements)
+ }
+
+ is IrWhen -> {
+ statementExpressions.add(statement)
+ statement.branches.forEach { recordStatements(it.result) }
+ }
+
+ is IrTypeOperatorCall -> recordStatements(statement.argument)
+ is IrCall -> plan.lets[statement]?.function?.body?.let { (it as IrBlockBody).statements.forEach(::recordStatements) }
+ is IrReturn -> recordStatements(statement.value)
+ }
+ }
+ plan.body.statements.forEach(::recordStatements)
+ val replacement = object : IrElementTransformerVoid() {
+ val aliases = mutableMapOf()
+
+ override fun visitGetValue(expression: IrGetValue): IrExpression =
+ aliases[expression.symbol]?.let { builder.irGet(it) } ?: expression
+
+ override fun visitWhen(expression: IrWhen): IrExpression {
+ val visited = super.visitWhen(expression) as IrWhen
+ // Attribute statements discard their result. Value expressions are visited separately.
+ if (expression in statementExpressions) visited.type = unit
+ return visited
+ }
+
+ override fun visitBlock(expression: IrBlock): IrExpression {
+ val visited = super.visitBlock(expression) as IrBlock
+ if (expression in statementExpressions) visited.type = unit
+ return visited
+ }
+
+ override fun visitCall(expression: IrCall): IrExpression {
+ val function = plan.lets[expression]?.function
+ val transformer = this
+ if (function != null) return builder.irBlock(resultType = unit) {
+ val parameter = function.parameters.single().symbol
+ val value = irTemporary(
+ requireNotNull(expression.arguments[0]).transform(transformer, null),
+ "htmlLetValue"
+ )
+ aliases[parameter] = value
+ for (statement in (function.body as IrBlockBody).statements) {
+ val result = statement.unwrapReturn(function.symbol)
+ +(result.transform(transformer, null) as IrStatement)
+ }
+ aliases.remove(parameter)
+ }
+ val original = byCall[expression] ?: return super.visitCall(expression)
+ val values = original.values.map { it.transform(this, null) }
+ return operation(original, values, state, builder)
+ }
+ }
+ return plan.body.statements.map { it.transform(replacement, null) as IrStatement }
+ }
+
+ private fun IrBlockBuilder.validateClasses(state: Slots, writer: IrVariable, validate: IrVariable) {
+ val classes = state.classes.values.flatten()
+ if (classes.isNotEmpty()) +irIfThen(
+ unit, irGet(validate),
+ irBlock(resultType = unit) {
+ for ((index, slot) in classes.withIndex()) {
+ var duplicate: IrExpression = irFalse()
+ for (previous in classes.take(index)) {
+ duplicate = irIfThenElse(
+ context.irBuiltIns.booleanType,
+ irEquals(irGet(slot), irGet(previous)), irTrue(), duplicate
+ )
+ }
+ +irCallWithArgs(symbols.validateMixedClass, irGet(writer), irGet(slot), irGet(validate), duplicate)
+ }
+ })
+ }
+
+ private fun IrBlockBuilder.writeAttributes(
+ plan: MixedAttributes, state: Slots, builder: DeclarationIrBuilder, writer: IrVariable,
+ ) {
+ val tag = plan.tag
+ val singleOperations = plan.attributes.groupBy { it.name }
+ fun literal(operation: AttributeOperation): String? {
+ val value = if (operation.values.isEmpty()) "" else
+ constants.stringWithoutNul(operation.values.single()) ?: return null
+ val boolean = isMinimizedBooleanAttribute(operation.name, tag, plan.namespace)
+ return " ${operation.name}" + if (boolean && value.isEmpty()) "" else "=\"${
+ escapeHtml(
+ value,
+ attribute = true
+ )
+ }\""
+ }
+
+ fun fixedLiteral(name: String): String? = singleOperations[name]?.singleOrNull()
+ ?.takeIf { it.unconditional && it.condition == null }?.let(::literal)
+
+ fun attribute(name: String): IrExpression {
+ val controlled = (name == "value" && AttributeOperationKind.FormValue in state.properties) ||
+ (name == "checked" && AttributeOperationKind.FormChecked in state.properties)
+ val single = if (controlled) null else singleOperations[name]?.singleOrNull()
+ val encoded = single?.let(::literal)
+ val boolean = isMinimizedBooleanAttribute(name, tag, plan.namespace)
+ if (encoded != null && fields.canStore(listOf(encoded))) return irIfThen(
+ unit,
+ irNotEquals(irGet(state.attributes.getValue(name)), irNull()),
+ irCallWithArgs(symbols.writeMixedLiteral, irGet(writer), fields.string(encoded, builder))
+ )
+ return irCallWithArgs(
+ symbols.writeMixedAttribute,
+ irGet(writer),
+ fields.string(" $name", builder),
+ irGet(state.attributes.getValue(name)),
+ irBoolean(boolean)
+ )
+ }
+
+ val opening = StringBuilder("<$tag")
+ var fixed = 0
+ if (state.count == null) for (name in state.names) {
+ val literal = fixedLiteral(name) ?: break
+ if (opening.length.toLong() + literal.length > MaxHtmlChunkLength) break
+ opening.append(literal)
+ fixed++
+ }
+ +irCallWithArgs(symbols.writeMixedLiteral, irGet(writer), fields.string(opening.toString(), builder))
+ if (state.count == null) {
+ for (name in state.names.drop(fixed)) +attribute(name)
+ } else {
+ val index = irTemporary(irInt(0), "htmlAttributeIndex", isMutable = true)
+ +irWhile().apply {
+ condition = irNotEquals(irGet(index), irGet(state.count))
+ this.body = irBlock(resultType = unit) {
+ for (name in state.names) {
+ +irIfThen(
+ unit,
+ irEquals(irGet(index), irGet(state.order.getValue(name))), attribute(name)
+ )
+ }
+ +irSet(index, irCallWithArgs(context.irBuiltIns.intPlusSymbol, irGet(index), irInt(1)))
+ }
+ }
+ }
+ }
+
+ private fun IrBlockBuilder.writeClasses(state: Slots, builder: DeclarationIrBuilder, writer: IrVariable) {
+ if (state.classes.isNotEmpty()) {
+ val outputClasses = irBlock(resultType = unit) {
+ val written = irTemporary(irFalse(), "htmlClassesWritten", isMutable = true)
+ for ((operation, slots) in state.classes) {
+ for ((slot, value) in slots.zip(operation.values)) {
+ val literal = constants.stringWithoutNul(value)
+ ?.let { escapeHtml(it, attribute = true) }
+ ?.takeIf { fields.canStore(listOf(it)) }
+ val value = if (literal == null) irGet(slot) else irIfThenElse(
+ nullableString, irEqualsNull(irGet(slot)), irNull(), fields.string(literal, builder)
+ )
+ +irSet(
+ written, irCallWithArgs(
+ symbols.writeMixedClass,
+ irGet(writer),
+ value,
+ irGet(written),
+ irBoolean(literal != null)
+ )
+ )
+ }
+ }
+ +irCallWithArgs(symbols.finishMixedClasses, irGet(writer), irGet(written))
+ }
+ val explicit = state.attributes["class"]
+ +if (explicit == null) outputClasses else irIfThen(
+ unit, irEqualsNull(irGet(explicit)), outputClasses
+ )
+ }
+ }
+}
diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlAttributes.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlAttributes.kt
new file mode 100644
index 0000000000..8c015cb561
--- /dev/null
+++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlAttributes.kt
@@ -0,0 +1,461 @@
+/*
+ * 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.
+ */
+
+// Access shared serialization helpers without exposing them as public API.
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+
+package org.jetbrains.compose.html.compiler.lowering
+
+import org.jetbrains.compose.html.compiler.HtmlSymbols
+import org.jetbrains.compose.html.compiler.HtmlSymbols.Companion.InputScope
+import org.jetbrains.compose.html.compiler.HtmlSymbols.Companion.TextAreaScope
+import org.jetbrains.compose.html.compiler.ir.KotlinLet
+import org.jetbrains.compose.html.compiler.ir.anyDescendant
+import org.jetbrains.compose.html.compiler.ir.irCallWithArgs
+import org.jetbrains.compose.html.compiler.ir.isAbsent
+import org.jetbrains.compose.html.compiler.ir.unwrapReturn
+import org.jetbrains.compose.html.compiler.ir.withOverrides
+import org.jetbrains.compose.web.dom.AsciiWhitespaceCharacters
+import org.jetbrains.compose.web.dom.HtmlNamespace
+import org.jetbrains.compose.web.dom.escapeHtml
+import org.jetbrains.compose.web.dom.isHtmlBooleanAttributeName
+import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
+import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder
+import org.jetbrains.kotlin.ir.IrElement
+import org.jetbrains.kotlin.ir.IrStatement
+import org.jetbrains.kotlin.ir.builders.irImplicitCast
+import org.jetbrains.kotlin.ir.builders.irString
+import org.jetbrains.kotlin.ir.declarations.IrFunction
+import org.jetbrains.kotlin.ir.declarations.IrParameterKind
+import org.jetbrains.kotlin.ir.expressions.IrBlockBody
+import org.jetbrains.kotlin.ir.expressions.IrCall
+import org.jetbrains.kotlin.ir.expressions.IrContainerExpression
+import org.jetbrains.kotlin.ir.expressions.IrExpression
+import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression
+import org.jetbrains.kotlin.ir.expressions.IrFunctionReference
+import org.jetbrains.kotlin.ir.expressions.IrGetValue
+import org.jetbrains.kotlin.ir.expressions.IrLoop
+import org.jetbrains.kotlin.ir.expressions.IrReturn
+import org.jetbrains.kotlin.ir.expressions.IrTypeOperator
+import org.jetbrains.kotlin.ir.expressions.IrTypeOperatorCall
+import org.jetbrains.kotlin.ir.expressions.IrVararg
+import org.jetbrains.kotlin.ir.expressions.IrWhen
+import org.jetbrains.kotlin.ir.symbols.IrFunctionSymbol
+import org.jetbrains.kotlin.ir.symbols.IrReturnTargetSymbol
+import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol
+import org.jetbrains.kotlin.ir.symbols.IrValueSymbol
+import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI
+import org.jetbrains.kotlin.ir.types.IrType
+import org.jetbrains.kotlin.ir.types.IrTypeSystemContextImpl
+import org.jetbrains.kotlin.ir.types.isBoolean
+import org.jetbrains.kotlin.ir.types.isString
+import org.jetbrains.kotlin.ir.types.makeNotNull
+import org.jetbrains.kotlin.ir.util.fqNameWhenAvailable
+import org.jetbrains.kotlin.ir.util.isNullable
+import org.jetbrains.kotlin.ir.util.isSubtypeOf
+
+internal sealed interface HtmlAttributes
+
+// Encoded attributes plus their original values for prepared-element rendering.
+internal data class LiteralAttributes(
+ val encoded: String,
+ val values: Map,
+ val classNames: List,
+) : HtmlAttributes {
+ fun openingTag(tag: String): String = "<$tag$encoded>"
+}
+
+internal enum class AttributeOperationKind { Attribute, Classes, Style, FormValue, FormChecked, TextContent, TextDefault, Ignore }
+
+internal class AttributeOperation(
+ val kind: AttributeOperationKind,
+ val call: IrCall?,
+ val name: String,
+ val values: List,
+ var condition: IrExpression? = null,
+ val valueIsAttribute: IrExpression? = null,
+ var unconditional: Boolean = true,
+)
+
+// Keeps the attrs body and its operations for runtime evaluation in the original order.
+internal class MixedAttributes(
+ val body: IrBlockBody,
+ val bodyOperations: List,
+ val lets: Map,
+ val tag: String,
+ val namespace: String,
+ val before: List,
+ val after: List,
+) : HtmlAttributes {
+ val operations: List = before + bodyOperations + after
+ val attributes = operations.filter { it.kind == AttributeOperationKind.Attribute }
+ val classes = operations.filter { it.kind == AttributeOperationKind.Classes }
+ val styles = operations.filter { it.kind == AttributeOperationKind.Style }
+}
+
+// Tries constant attributes first, then collects supported operations for runtime rendering.
+@OptIn(UnsafeDuringIrConstructionAPI::class)
+internal class HtmlAttributeAnalyzer(
+ private val context: IrPluginContext,
+ private val symbols: HtmlSymbols,
+ private val constants: ConstantEvaluator,
+) {
+ // Returns literal or mixed attributes, or null if the callback cannot be safely lowered.
+ fun analyze(
+ expression: IrExpression?,
+ tag: String,
+ before: List,
+ after: List,
+ namespace: String,
+ valueIsAttribute: IrExpression?,
+ ): HtmlAttributes? {
+ if (tag.length.toLong() + 2 > MaxEncodedFragmentLength) return null
+ val lambda = if (expression.isAbsent()) null else (expression as? IrFunctionExpression ?: return null)
+ val receiver = lambda?.function?.parameters?.singleOrNull()?.symbol
+ if (lambda != null && receiver == null) return null
+ val body = if (lambda == null) context.irFactory.createBlockBody(0, 0) else lambda.function.body as? IrBlockBody
+ ?: return null
+ val analysis = Analysis(receiver, namespace, tag, valueIsAttribute)
+ if (!body.statements.all { analysis.analyze(it, unconditional = true) }) return null
+ val operations = before + analysis.operations + after
+ if (operations.any {
+ it.condition != null || when (it.kind) {
+ AttributeOperationKind.Attribute, AttributeOperationKind.Classes -> false
+ AttributeOperationKind.Ignore -> it.values.isNotEmpty()
+ else -> true
+ }
+ }) analysis.literal = false
+ if (namespace != HtmlNamespace) {
+ val names = operations.filter { it.kind != AttributeOperationKind.Classes }.map { it.name }.distinct()
+ if (names.map { it.lowercase() }.distinct().size != names.size) return null
+ }
+ if (analysis.literal) {
+ literalAttributes(operations, tag, namespace)
+ ?.takeIf { tag.length.toLong() + it.encoded.length + 2 <= MaxEncodedFragmentLength }
+ ?.let { return it }
+ }
+ if (operations.size + operations.sumOf { it.values.size } > MaxMixedAttributeOperations ||
+ operations.filter { it.kind == AttributeOperationKind.Classes }
+ .sumOf { it.values.size } > MaxMixedClassTokens) return null
+ return MixedAttributes(body, analysis.operations.toList(), analysis.lets, tag, namespace, before, after)
+ }
+
+ private inner class Analysis(
+ val receiver: IrValueSymbol?,
+ val namespace: String,
+ val tag: String,
+ val valueIsAttribute: IrExpression?,
+ ) {
+ val operations = mutableListOf()
+ val lets = mutableMapOf()
+ var literal = true // The operations may be encoded as HTML if their values are constant.
+
+ private fun matches(actual: IrSimpleFunctionSymbol, expected: IrSimpleFunctionSymbol): Boolean =
+ actual.withOverrides().any { it == expected }
+
+ // The value must not use the attrs receiver or return from an enclosing function.
+ // loadTitle() can pass this check. customHelper(this) cannot.
+ private fun independent(element: IrElement): Boolean {
+ val localFunctions = mutableSetOf()
+ val returns = mutableSetOf()
+ val capturesReceiver = element.anyDescendant {
+ when (it) {
+ is IrFunction -> localFunctions.add(it.symbol)
+ is IrReturn -> returns.add(it.returnTargetSymbol)
+ }
+ it is IrGetValue && it.symbol == receiver
+ }
+ return !capturesReceiver && localFunctions.containsAll(returns)
+ }
+
+ // Collect operations from one attrs statement. False rejects the whole callback.
+ fun analyze(statement: IrStatement, unconditional: Boolean = false): Boolean {
+ if (constants.isConstantAlias(statement)) return true
+ if (statement !is IrCall && (statement !is IrTypeOperatorCall || statement.argument !is IrCall)) literal =
+ false
+ return when (statement) {
+ is IrTypeOperatorCall -> statement.operator == IrTypeOperator.IMPLICIT_COERCION_TO_UNIT &&
+ analyze(statement.argument, unconditional)
+
+ is IrWhen -> statement.branches.all {
+ independent(it.condition) && analyze(it.result)
+ }
+
+ is IrContainerExpression -> statement.statements.all { analyze(it) }
+ is IrCall -> analyzeCall(statement, unconditional)
+
+ is IrLoop, is IrReturn -> false
+ else -> independent(statement)
+ }
+ }
+
+ private fun analyzeCall(call: IrCall, unconditional: Boolean): Boolean {
+ if (call.symbol.owner.fqNameWhenAvailable == KotlinLet) return analyzeLet(call)
+ if ((call.arguments.firstOrNull() as? IrGetValue)?.symbol != receiver) {
+ literal = false
+ return independent(call)
+ }
+ val operation = attributeOperation(call) ?: return false
+ // Pure ignored callbacks and annotated helpers can join literal attributes.
+ if (call.symbol != symbols.classes && call.symbol != symbols.id && call.symbol != symbols.attr &&
+ symbols.attribute(call.symbol) == null &&
+ !(operation.kind == AttributeOperationKind.Ignore && operation.values.isEmpty())
+ ) {
+ literal = false
+ }
+ if (operation.values.any { !independent(it) }) return false
+ operation.unconditional = unconditional
+ operations.add(operation)
+ return true
+ }
+
+ private fun analyzeLet(call: IrCall): Boolean {
+ literal = false
+ val value = call.arguments.getOrNull(0) ?: return false
+ val function = call.arguments.getOrNull(1) as? IrFunctionExpression ?: return false
+ val statements = (function.function.body as? IrBlockBody)?.statements ?: return false
+ if (function.function.parameters.size != 1 || !independent(value)) return false
+ if (!statements.all {
+ analyze(it.unwrapReturn(function.function.symbol))
+ }) return false
+ lets[call] = function
+ return true
+ }
+
+ private fun attributeOperation(call: IrCall): AttributeOperation? {
+ when {
+ matches(call.symbol, symbols.classes) -> {
+ val vararg = call.arguments.getOrNull(1) as? IrVararg ?: return null
+ val values = vararg.elements.map { it as? IrExpression ?: return null }
+ return AttributeOperation(AttributeOperationKind.Classes, call, "", values)
+ }
+
+ matches(call.symbol, symbols.id) -> return AttributeOperation(
+ AttributeOperationKind.Attribute, call, "id", listOf(call.arguments.getOrNull(1) ?: return null)
+ )
+
+ matches(call.symbol, symbols.attr) -> {
+ val name = constants.string(call.arguments.getOrNull(1)) ?: return null
+ if (!isLowerableAttributeName(name, namespace)) return null
+ return AttributeOperation(
+ AttributeOperationKind.Attribute, call, name, listOf(call.arguments.getOrNull(2) ?: return null)
+ )
+ }
+
+ matches(call.symbol, symbols.style) -> return AttributeOperation(
+ AttributeOperationKind.Style, call, "", listOf(call.arguments.getOrNull(1) ?: return null)
+ )
+
+ isIgnored(call.symbol) -> return AttributeOperation(
+ AttributeOperationKind.Ignore, call, "", call.arguments.drop(1).filterNotNull().flatMap {
+ when (it) {
+ is IrFunctionExpression -> emptyList()
+ is IrFunctionReference -> it.arguments.filterNotNull()
+ else -> listOf(it)
+ }
+ }
+ )
+ }
+ // Only specialized scope members and annotated helpers need an IR builder.
+ val builder = DeclarationIrBuilder(context, call.symbol)
+ return scopeOperation(call.symbol, call.arguments.getOrNull(1), call, tag, valueIsAttribute, builder)
+ ?: helperOperation(call.symbol, call.arguments.getOrNull(1), call, builder)
+ }
+ }
+
+ private fun stringValue(value: IrExpression, builder: DeclarationIrBuilder): IrExpression =
+ if (value.type.isString()) value else builder.irCallWithArgs(symbols.anyToString, value)
+
+ // Match a wrapper parameter to its attribute helper and convert the value if needed.
+ fun wrapperOperation(
+ name: String,
+ value: IrExpression,
+ namespace: String,
+ builder: DeclarationIrBuilder,
+ parameterType: IrType,
+ receiver: IrType,
+ tag: String,
+ valueIsAttribute: IrExpression?,
+ ): AttributeOperation? {
+ val typeSystem = IrTypeSystemContextImpl(context.irBuiltIns)
+ val helpers = symbols.helpers(name, namespace, receiver).filter { helper ->
+ helper.owner.parameters.firstOrNull()?.let {
+ it.kind == IrParameterKind.DispatchReceiver || receiver.isSubtypeOf(it.type, typeSystem)
+ } == true
+ }
+ val type = parameterType.makeNotNull()
+ val helper =
+ helpers.firstOrNull { it.owner.parameters.size == 2 && it.owner.parameters[1].type.makeNotNull() == type }
+ ?: helpers.firstOrNull { type.isBoolean() && it.owner.parameters.size == 1 }
+ ?: helpers.firstOrNull { it.owner.parameters.size == 2 && it.owner.parameters[1].type.isString() }
+ ?: return null
+ val expected = helper.owner.parameters.getOrNull(1)?.type
+ val converted = if (expected?.isString() == true && !type.isString()) stringValue(value, builder) else
+ if (value.type.isNullable()) builder.irImplicitCast(value, type) else value
+ return scopeOperation(helper, converted, null, tag, valueIsAttribute, builder)
+ ?: helperOperation(helper, converted, null, builder)
+ }
+
+ private fun scopeOperation(
+ symbol: IrSimpleFunctionSymbol,
+ value: IrExpression?,
+ call: IrCall?,
+ tag: String,
+ valueIsAttribute: IrExpression?,
+ builder: DeclarationIrBuilder,
+ ): AttributeOperation? {
+ if (tag == "input" && isMember(symbol, InputScope, "value", "defaultValue", "checked", "defaultChecked")) {
+ return when (symbol.owner.name.asString()) {
+ "checked" -> AttributeOperation(
+ AttributeOperationKind.FormChecked,
+ call,
+ "checked",
+ listOf(value ?: return null)
+ )
+
+ "defaultChecked" -> AttributeOperation(AttributeOperationKind.Attribute, call, "checked", emptyList())
+ "defaultValue" -> AttributeOperation(
+ AttributeOperationKind.Attribute, call, "value", listOf(stringValue(value ?: return null, builder))
+ )
+
+ else -> AttributeOperation(
+ AttributeOperationKind.FormValue, call, "value", listOf(stringValue(value ?: return null, builder)),
+ valueIsAttribute = valueIsAttribute ?: return null
+ )
+ }
+ }
+ if (tag == "textarea" && isMember(symbol, TextAreaScope, "value", "defaultValue")) {
+ val kind = if (symbol.owner.name.asString() == "value") {
+ AttributeOperationKind.TextContent
+ } else AttributeOperationKind.TextDefault
+ return AttributeOperation(kind, call, "", listOf(value ?: return null))
+ }
+ return null
+ }
+
+ private fun helperOperation(
+ symbol: IrSimpleFunctionSymbol,
+ value: IrExpression?,
+ call: IrCall?,
+ builder: DeclarationIrBuilder,
+ ): AttributeOperation? {
+ val helper = symbols.attribute(symbol) ?: return null
+ if (value == null && symbol.owner.parameters.size == 2 && helper.defaultValue != null) {
+ return AttributeOperation(
+ AttributeOperationKind.Attribute,
+ call,
+ helper.name,
+ listOf(builder.irString(helper.defaultValue))
+ )
+ }
+ val values = if (symbol.owner.parameters.size == 1) {
+ if (helper.value.isEmpty()) emptyList() else listOf(builder.irString(helper.value))
+ } else {
+ val argument = value ?: return null
+ val integer = if (helper.conversion == symbols.formatNumber) constants.integer(argument) else null
+ listOf(
+ when {
+ integer != null -> builder.irString(integer.toString())
+ helper.conversion != null -> builder.irCallWithArgs(helper.conversion, argument)
+ else -> argument
+ }
+ )
+ }
+ return AttributeOperation(AttributeOperationKind.Attribute, call, helper.name, values)
+ }
+
+ private fun isMember(symbol: IrSimpleFunctionSymbol, owner: String, vararg names: String): Boolean =
+ symbol.withOverrides().any { it.owner.fqNameWhenAvailable?.asString() in names.map { "$owner.$it" } }
+
+ private fun isIgnored(symbol: IrSimpleFunctionSymbol): Boolean = symbol.withOverrides().any {
+ val fqName = it.owner.fqNameWhenAvailable?.asString().orEmpty()
+ val name = it.owner.name.asString()
+ it.owner.fqNameWhenAvailable in HtmlSymbols.FormEvents ||
+ fqName.startsWith("${HtmlSymbols.EventsScope}.") ||
+ (name.startsWith("on") && listOf(
+ InputScope,
+ TextAreaScope,
+ HtmlSymbols.SelectScope
+ ).any { fqName == "$it.$name" }) ||
+ fqName == "${HtmlSymbols.AttrsScope.asSingleFqName()}.ref"
+ }
+
+ /**
+ * Encodes constant attributes and keeps their unescaped values for later use.
+ *
+ * ```kotlin
+ * attrs = {
+ * id("profile")
+ * attr("title", "A&B")
+ * classes("card", "selected")
+ * }
+ * ```
+ *
+ * Produces:
+ * ```html
+ * id="profile" title="A&B" class="card selected"
+ * ```
+ *
+ * Returns `null` for dynamic values, NUL, duplicate names, invalid or duplicate class tokens,
+ * or attributes that exceed the size limit. The caller can then try runtime attribute writes.
+ */
+ private fun literalAttributes(
+ operations: List,
+ tagName: String,
+ namespace: String
+ ): LiteralAttributes? {
+ val classNames = mutableListOf()
+ val values = linkedMapOf()
+
+ for (operation in operations) {
+ if (operation.kind == AttributeOperationKind.Ignore) continue
+ if (operation.kind == AttributeOperationKind.Classes) {
+ for (expression in operation.values) {
+ val value = constants.stringWithoutNul(expression) ?: return null
+ if (value.isEmpty() || value.any { it in InvalidClassTokenCharacters } || value in classNames) return null
+ classNames.add(value)
+ }
+ } else {
+ val value =
+ if (operation.values.isEmpty()) "" else constants.stringWithoutNul(operation.values.single())
+ ?: return null
+ if (operation.name in values) return null
+ values[operation.name] = value
+ }
+ }
+
+ val encoded = buildString {
+ for ((name, value) in values) {
+ append(' ').append(name)
+ if (value.isNotEmpty() || !isMinimizedBooleanAttribute(name, tagName, namespace)) {
+ append("=\"").append(escapeHtml(value, attribute = true)).append('"')
+ }
+ if (length > MaxEncodedFragmentLength) return null
+ }
+ // The runtime serializes ordinary attributes first and class tokens last.
+ if (classNames.isNotEmpty() && "class" !in values) {
+ append(" class=\"")
+ append(escapeHtml(classNames.joinToString(" "), attribute = true)).append('"')
+ }
+ if (length > MaxEncodedFragmentLength) return null
+ }
+ return LiteralAttributes(encoded, values, classNames)
+ }
+}
+
+private const val InvalidClassTokenCharacters = AsciiWhitespaceCharacters + '\u0000'
+
+private val LowerableAttributeName = Regex("[a-z][a-z0-9_.:-]*")
+
+// Keep reserved data-compose names and unsupported names on the runtime path.
+internal fun isLowerableAttributeName(name: String, namespace: String): Boolean =
+ (if (namespace == HtmlNamespace) LowerableAttributeName else ForeignAttributeName).matches(name) &&
+ !name.startsWith("data-compose", ignoreCase = true)
+
+private val ForeignAttributeName = Regex("[a-zA-Z][a-zA-Z0-9_.:-]*")
+
+// Custom elements and foreign namespaces preserve empty boolean attribute values.
+internal fun isMinimizedBooleanAttribute(name: String, tag: String, namespace: String): Boolean =
+ namespace == HtmlNamespace && '-' !in tag && name.isHtmlBooleanAttributeName()
diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlFields.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlFields.kt
new file mode 100644
index 0000000000..e12a1a3488
--- /dev/null
+++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlFields.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.html.compiler.lowering
+
+import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
+import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder
+import org.jetbrains.kotlin.descriptors.DescriptorVisibilities
+import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET
+import org.jetbrains.kotlin.ir.builders.declarations.buildField
+import org.jetbrains.kotlin.ir.builders.irGetField
+import org.jetbrains.kotlin.ir.builders.irString
+import org.jetbrains.kotlin.ir.declarations.IrDeclarationWithName
+import org.jetbrains.kotlin.ir.declarations.IrField
+import org.jetbrains.kotlin.ir.declarations.IrFile
+import org.jetbrains.kotlin.ir.declarations.createExpressionBody
+import org.jetbrains.kotlin.ir.expressions.IrExpression
+import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI
+import org.jetbrains.kotlin.ir.types.IrType
+import org.jetbrains.kotlin.name.Name
+
+// Creates private fields with unique names and shares large strings within one file.
+@OptIn(UnsafeDuringIrConstructionAPI::class)
+internal class HtmlFields(private val context: IrPluginContext, private val file: IrFile) {
+ private val usedNames =
+ file.declarations.filterIsInstance().mapTo(mutableSetOf()) { it.name }
+ private val shared = mutableMapOf()
+ private var length = 0
+ val stringFields: Collection get() = shared.values
+
+ // Count only new large strings against the file's cache limit.
+ fun canStore(values: Collection): Boolean =
+ length.toLong() + values.toSet().sumOf {
+ if (it.length > MaxHtmlChunkLength && it !in shared) it.length.toLong() else 0L
+ } <= MaxCachedStringLengthPerFile
+
+ // Use a literal for small strings and a shared field for large ones.
+ fun string(value: String, builder: DeclarationIrBuilder): IrExpression {
+ if (value.length <= MaxHtmlChunkLength) return builder.irString(value)
+ val field = shared.getOrPut(value) {
+ check(canStore(listOf(value)))
+ length += value.length
+ create("__htmlString", shared.size, context.irBuiltIns.stringType).apply {
+ // A plain oversized literal lets the backend split the constant.
+ initializer = context.irFactory.createExpressionBody(builder.irString(value))
+ }
+ }
+ return builder.irGetField(null, field)
+ }
+
+ fun create(
+ prefix: String,
+ index: Int,
+ type: IrType,
+ startOffset: Int = UNDEFINED_OFFSET,
+ endOffset: Int = UNDEFINED_OFFSET,
+ ): IrField = context.irFactory.buildField {
+ name = generateSequence(index) { it + 1 }.map { Name.identifier("$prefix$it") }.first(usedNames::add)
+ this.type = type
+ visibility = DescriptorVisibilities.PRIVATE
+ isFinal = true
+ isStatic = true
+ this.startOffset = startOffset
+ this.endOffset = endOffset
+ }.apply { parent = file }
+}
diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/LoweringLimits.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/LoweringLimits.kt
new file mode 100644
index 0000000000..2ce47661b5
--- /dev/null
+++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/LoweringLimits.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.html.compiler.lowering
+
+// Keep generated code and compiler work within these limits.
+// When a limit is reached, leave the remaining work to runtime rendering.
+
+// Constant evaluation: bound compiler time and memory.
+internal const val MaxEvaluatedStringLength = 1_000_000
+
+// Expression and alias recursion.
+internal const val MaxFoldDepth = 64
+
+// Work per expression.
+internal const val MaxFoldSteps = 2_048
+
+// IR interpreter stack and work per operation.
+internal const val MaxInterpreterStackDepth = 64
+internal const val MaxInterpreterCommands = 2_048
+
+// Static HTML: bound fragment size and expansion.
+internal const val MaxEncodedFragmentLength = 1_000_000
+internal const val MaxExpandedHtmlCalls = 8_000
+
+// Subtree height, cached per node.
+internal const val MaxHtmlNestingDepth = 64
+
+// Traverse deeper to find lowerable children under rejected roots.
+internal const val MaxEncodingRecursion = 4 * MaxHtmlNestingDepth
+internal const val MaxStaticRepeatCount = 10_000
+
+// Attributes: keep callbacks below the JVM's 64 KiB method limit.
+internal const val MaxPreparedAttributeOperations = 128
+internal const val MaxMixedAttributeOperations = 128
+
+// Duplicate checks grow quadratically.
+internal const val MaxMixedClassTokens = 32
+
+// Cap fields and constant-pool use.
+internal const val MaxPreparedAttributesPerFile = 256
+
+// Chunks fit the JVM's 65,535-byte modified UTF-8 constant limit.
+internal const val MaxHtmlChunkLength = 8_000
+
+// Bound method size and constant-pool use.
+internal const val MaxGeneratedChunksPerFunction = 128
+internal const val MaxGeneratedChunksPerFile = 1_024
+
+// Cap cached data and string initialization code.
+internal const val MaxCachedStringLengthPerFile = 4_000_000
diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/PreparedElementLowering.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/PreparedElementLowering.kt
new file mode 100644
index 0000000000..2d3ffe251b
--- /dev/null
+++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/PreparedElementLowering.kt
@@ -0,0 +1,205 @@
+/*
+ * 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.
+ */
+
+// Access shared serialization helpers without exposing them as public API.
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+
+package org.jetbrains.compose.html.compiler.lowering
+
+import org.jetbrains.compose.html.compiler.HtmlSymbols
+import org.jetbrains.compose.html.compiler.ir.anyDescendant
+import org.jetbrains.compose.html.compiler.ir.irLambda
+import org.jetbrains.compose.web.dom.HtmlNamespace
+import org.jetbrains.compose.web.dom.supportsPreparedHtmlAttributes
+import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
+import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder
+import org.jetbrains.kotlin.ir.builders.declarations.buildValueParameter
+import org.jetbrains.kotlin.ir.builders.irBlockBody
+import org.jetbrains.kotlin.ir.builders.irCall
+import org.jetbrains.kotlin.ir.builders.irGet
+import org.jetbrains.kotlin.ir.builders.irGetField
+import org.jetbrains.kotlin.ir.builders.irReturn
+import org.jetbrains.kotlin.ir.builders.irString
+import org.jetbrains.kotlin.ir.builders.irVararg
+import org.jetbrains.kotlin.ir.declarations.IrField
+import org.jetbrains.kotlin.ir.declarations.IrParameterKind
+import org.jetbrains.kotlin.ir.declarations.createExpressionBody
+import org.jetbrains.kotlin.ir.expressions.IrBlockBody
+import org.jetbrains.kotlin.ir.expressions.IrCall
+import org.jetbrains.kotlin.ir.expressions.IrExpression
+import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression
+import org.jetbrains.kotlin.ir.expressions.IrReturn
+import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol
+import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI
+import org.jetbrains.kotlin.ir.types.IrSimpleType
+import org.jetbrains.kotlin.ir.types.IrType
+import org.jetbrains.kotlin.ir.types.IrTypeSubstitutor
+import org.jetbrains.kotlin.ir.types.classOrNull
+import org.jetbrains.kotlin.ir.types.impl.makeTypeProjection
+import org.jetbrains.kotlin.ir.types.makeNotNull
+import org.jetbrains.kotlin.ir.types.typeOrNull
+import org.jetbrains.kotlin.ir.types.typeWith
+import org.jetbrains.kotlin.ir.util.patchDeclarationParents
+import org.jetbrains.kotlin.name.Name
+import org.jetbrains.kotlin.types.Variance
+
+/**
+ * Caches constant attributes when an element still needs runtime rendering.
+ * The attributes are stored in a shared field.
+ *
+ * Need in cases like:
+ * - Content receiver references.
+ * - Labeled content returns.
+ * - Stored or nullable content.
+ *
+ * ```kotlin
+ * @Composable
+ * fun Label(
+ * text: @Composable ElementScope.() -> String
+ * ) {
+ * Span(attrs = { classes("label") }) {
+ * Text(text()) // needs ElementScope receiver
+ * }
+ * }
+ * private val __htmlAttributes0 = /* prepared attributes */
+ *
+ * @Composable
+ * fun Label(
+ * text: @Composable ElementScope.() -> String
+ * ) {
+ * PreparedTextElement(__htmlAttributes0) { text() }
+ * }
+ * ```
+ */
+@OptIn(UnsafeDuringIrConstructionAPI::class)
+internal class PreparedElementLowering(
+ private val context: IrPluginContext,
+ private val symbols: HtmlSymbols,
+ private val generatedFields: HtmlFields,
+) {
+ private data class AttributeKey(val type: IrType, val tag: String, val attributes: LiteralAttributes)
+ private data class WrapperTypes(val attrs: IrType, val receiver: IrType, val element: IrType)
+
+ private val sharedAttributes = mutableMapOf()
+ val fields: Collection get() = sharedAttributes.values
+
+ // Reuses constant attributes for supported HTML wrappers. Returns null if this is not possible.
+ fun lower(element: HtmlElement, builder: DeclarationIrBuilder): IrExpression? {
+ val attributes = element.attributes as? LiteralAttributes ?: return null
+ if (element.namespace != HtmlNamespace || element.call.arguments.size != 2 ||
+ !supportsPreparedHtmlAttributes(element.tag)
+ ) return null
+ if (attributes.values.size + attributes.classNames.size > MaxPreparedAttributeOperations ||
+ sharedAttributes.size >= MaxPreparedAttributesPerFile
+ ) return null
+ val types = wrapperTypes(element.call) ?: return null
+ val values = attributes.values
+ if (!generatedFields.canStore(
+ values.keys + values.values + attributes.classNames +
+ listOf(requireNotNull(element.openingTag))
+ )
+ ) return null
+ val attrs = prepareAttributes(element, attributes, types)
+ // Read content after the transformer has lowered the children.
+ val content = element.call.arguments[1] as? IrFunctionExpression
+ if (content == null) {
+ // Stored or nullable content keeps its original wrapper and argument evaluation.
+ element.call.arguments[0] = builder.irCall(symbols.attributesCallback).apply {
+ type = types.attrs
+ arguments[0] = builder.irGetField(null, attrs)
+ }
+ return element.call
+ }
+ val text = (content.function.body as? IrBlockBody)?.statements?.singleOrNull() as? IrCall
+ val value = text?.takeIf { it.symbol == symbols.text }?.arguments?.singleOrNull()
+ val target = if (value != null && !returnsFromContent(value, content)) {
+ // Reuse the function and receiver symbols, including references in nested lambdas.
+ content.function.returnType = context.irBuiltIns.stringType
+ content.type = parameterType(symbols.preparedTextElement, 1, types.element)
+ val bodyBuilder = DeclarationIrBuilder(context, content.function.symbol, value.startOffset, value.endOffset)
+ content.function.body = bodyBuilder.irBlockBody { +irReturn(value) }
+ symbols.preparedTextElement
+ } else {
+ symbols.preparedContainer
+ }
+ return builder.irCall(target).apply {
+ typeArguments[0] = types.element
+ arguments[0] = builder.irGetField(null, attrs)
+ arguments[1] = content
+ }
+ }
+
+ private fun wrapperTypes(call: IrCall): WrapperTypes? {
+ // The wrapper accepts Function1, Unit>.
+ val attrs = call.symbol.owner.parameters[0].type.makeNotNull()
+ val receiver = (attrs as? IrSimpleType)?.arguments?.firstOrNull()?.typeOrNull ?: return null
+ val element = (receiver as? IrSimpleType)?.arguments?.singleOrNull()?.typeOrNull ?: return null
+ return WrapperTypes(attrs, receiver, element)
+ }
+
+ private fun returnsFromContent(value: IrExpression, content: IrFunctionExpression): Boolean =
+ value.anyDescendant { it is IrReturn && it.returnTargetSymbol == content.function.symbol }
+
+ private fun parameterType(symbol: IrSimpleFunctionSymbol, index: Int, elementType: IrType): IrType =
+ IrTypeSubstitutor(
+ symbol.owner.typeParameters.map { it.symbol },
+ listOf(makeTypeProjection(elementType, Variance.INVARIANT)),
+ ).substitute(symbol.owner.parameters[index].type)
+
+ // Reuse one field for the same element type, tag and attributes in this file.
+ private fun prepareAttributes(element: HtmlElement, attributes: LiteralAttributes, types: WrapperTypes): IrField {
+ val type = requireNotNull(symbols.preparedHtmlAttributes.owner.returnType.classOrNull).typeWith(types.element)
+ return sharedAttributes.getOrPut(AttributeKey(type, element.tag, attributes)) {
+ val field = generatedFields.create(
+ "__htmlAttributes", sharedAttributes.size, type, element.call.startOffset, element.call.endOffset
+ )
+ val builder = DeclarationIrBuilder(context, field.symbol, element.call.startOffset, element.call.endOffset)
+ field.initializer = context.irFactory.createExpressionBody(
+ builder.irCall(symbols.preparedHtmlAttributes).apply {
+ this.type = type
+ typeArguments[0] = types.element
+ arguments[0] = builder.irString(element.tag)
+ arguments[1] = generatedFields.string(requireNotNull(element.openingTag), builder)
+ arguments[2] = attributeInitializer(attributes, types, builder)
+ }
+ )
+ field.patchDeclarationParents(field.parent)
+ field
+ }
+ }
+
+ private fun attributeInitializer(
+ attributes: LiteralAttributes,
+ types: WrapperTypes,
+ parent: DeclarationIrBuilder,
+ ): IrExpression = parent.irLambda(context, types.attrs) {
+ // Rebuild from folded values so file initialization never captures render-time locals.
+ val receiver = buildValueParameter(this) {
+ name = Name.special("")
+ type = types.receiver
+ kind = IrParameterKind.ExtensionReceiver
+ }
+ parameters = listOf(receiver)
+ val builder = DeclarationIrBuilder(context, symbol, parent.startOffset, parent.endOffset)
+ body = builder.irBlockBody {
+ for ((name, value) in attributes.values) {
+ +builder.irCall(symbols.attr).apply {
+ type = types.receiver
+ arguments[0] = builder.irGet(receiver)
+ arguments[1] = generatedFields.string(name, builder)
+ arguments[2] = generatedFields.string(value, builder)
+ }
+ }
+ if (attributes.classNames.isNotEmpty()) {
+ +builder.irCall(symbols.classes).apply {
+ arguments[0] = builder.irGet(receiver)
+ arguments[1] = builder.irVararg(
+ context.irBuiltIns.stringType, attributes.classNames.map { generatedFields.string(it, builder) }
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/WrapperCallNormalizer.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/WrapperCallNormalizer.kt
new file mode 100644
index 0000000000..1cc7402da3
--- /dev/null
+++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/WrapperCallNormalizer.kt
@@ -0,0 +1,309 @@
+/*
+ * 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.
+ */
+
+// Access shared serialization helpers without exposing them as public API.
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+
+package org.jetbrains.compose.html.compiler.lowering
+
+import java.util.IdentityHashMap
+import org.jetbrains.compose.html.compiler.HtmlSymbols
+import org.jetbrains.compose.html.compiler.ir.irCallWithArgs
+import org.jetbrains.compose.html.compiler.ir.irLambda
+import org.jetbrains.compose.html.compiler.ir.isAbsent
+import org.jetbrains.compose.web.dom.HtmlNamespace
+import org.jetbrains.compose.web.dom.isValidHtmlTagName
+import org.jetbrains.kotlin.backend.common.IrElementTransformerVoidWithContext
+import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
+import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder
+import org.jetbrains.kotlin.ir.builders.IrBlockBuilder
+import org.jetbrains.kotlin.ir.builders.irBlock
+import org.jetbrains.kotlin.ir.builders.irBlockBody
+import org.jetbrains.kotlin.ir.builders.irBoolean
+import org.jetbrains.kotlin.ir.builders.irFalse
+import org.jetbrains.kotlin.ir.builders.irGet
+import org.jetbrains.kotlin.ir.builders.irIfThenElse
+import org.jetbrains.kotlin.ir.builders.irInt
+import org.jetbrains.kotlin.ir.builders.irNotEquals
+import org.jetbrains.kotlin.ir.builders.irNull
+import org.jetbrains.kotlin.ir.builders.irString
+import org.jetbrains.kotlin.ir.builders.irTemporary
+import org.jetbrains.kotlin.ir.declarations.IrFunction
+import org.jetbrains.kotlin.ir.declarations.IrDeclarationParent
+import org.jetbrains.kotlin.ir.declarations.IrParameterKind
+import org.jetbrains.kotlin.ir.declarations.IrValueParameter
+import org.jetbrains.kotlin.ir.expressions.IrAnnotation
+import org.jetbrains.kotlin.ir.expressions.IrCall
+import org.jetbrains.kotlin.ir.expressions.IrConst
+import org.jetbrains.kotlin.ir.expressions.IrExpression
+import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression
+import org.jetbrains.kotlin.ir.expressions.IrGetValue
+import org.jetbrains.kotlin.ir.expressions.IrVararg
+import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI
+import org.jetbrains.kotlin.ir.types.IrSimpleType
+import org.jetbrains.kotlin.ir.types.isBoolean
+import org.jetbrains.kotlin.ir.types.isString
+import org.jetbrains.kotlin.ir.types.makeNotNull
+import org.jetbrains.kotlin.ir.types.typeOrNull
+import org.jetbrains.kotlin.ir.types.typeWith
+import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols
+import org.jetbrains.kotlin.ir.util.fqNameWhenAvailable
+import org.jetbrains.kotlin.ir.util.isNullable
+import org.jetbrains.kotlin.ir.util.patchDeclarationParents
+import org.jetbrains.kotlin.name.Name
+
+// A recognized HTML call with its tag, namespace, analyzed attributes and content.
+internal class HtmlElement(
+ val call: IrCall,
+ val tag: String,
+ val attributes: HtmlAttributes,
+ val namespace: String,
+ val content: IrExpression?,
+) {
+ val openingTag: String?
+ get() = (attributes as? LiteralAttributes)?.openingTag(tag)
+}
+
+/**
+ * Collects the tag, attributes and content of supported HTML calls.
+ * Dynamic arguments are stored in temporary variables so they run once.
+ * The original call remains available if later lowering cannot replace it.
+ *
+ * ```kotlin
+ * A(href = computeUrl(), attrs = { attr("title", "Details") }) {
+ * Text("Open")
+ * }
+ * ```
+ *
+ * This gives an `a` element with `href` and `title` attributes and a `Text("Open")` body.
+ * `computeUrl()` still runs at runtime.
+ */
+@OptIn(UnsafeDuringIrConstructionAPI::class)
+internal class WrapperCallNormalizer(
+ private val context: IrPluginContext,
+ private val symbols: HtmlSymbols,
+ private val constants: ConstantEvaluator,
+ private val analyzer: HtmlAttributeAnalyzer,
+) : IrElementTransformerVoidWithContext() {
+ val elements = IdentityHashMap()
+
+ private class TagSpec(marker: IrAnnotation?) {
+ private val arguments = marker?.argumentMapping.orEmpty()
+ private fun string(name: String) =
+ (arguments[Name.identifier(name)] as? IrConst)?.value as? String ?: ""
+
+ private fun strings(name: String) = (arguments[Name.identifier(name)] as? IrVararg)?.elements
+ ?.mapNotNull { (it as? IrConst)?.value as? String }.orEmpty()
+
+ val tagName = string("tagName")
+ val namespace = string("namespace").ifEmpty { HtmlNamespace }
+ val text = string("text")
+ val inputType = string("inputType")
+ val omitEmpty = strings("omitEmpty").toSet()
+ val afterAttrs = strings("afterAttrs").toSet()
+ val defaults = strings("defaults").associate {
+ it.substringBefore('=') to it.takeIf { '=' in it }?.substringAfter('=')
+ }
+ }
+
+ private class Wrapper(
+ val call: IrCall,
+ val spec: TagSpec,
+ val tag: String,
+ val namespace: String,
+ val attrs: IrValueParameter?,
+ val text: IrValueParameter?,
+ val content: IrValueParameter?,
+ val inputType: IrValueParameter?,
+ val positional: List,
+ ) {
+ val hoisted = (positional + listOfNotNull(text, inputType)).sortedBy { it.indexInParameters }
+ }
+
+ override fun visitCall(expression: IrCall): IrExpression {
+ val call = super.visitCall(expression) as IrCall
+ val wrapper = recognize(call) ?: return call
+ val builder = DeclarationIrBuilder(
+ context, requireNotNull(currentScope).scope.scopeOwnerSymbol, call.startOffset, call.endOffset
+ )
+ val defaults = defaults(wrapper, builder) ?: return call
+ return builder.irBlock(resultType = call.type) {
+ val temporaries = hoistArguments(wrapper, defaults)
+ val attributes = wrapperAttributes(wrapper, temporaries, builder) ?: return call
+ val content = wrapper.text?.let { textContent(temporaries.getValue(it), builder) }
+ ?: call.argument(wrapper.content)
+ elements[call] = HtmlElement(call, wrapper.tag, attributes, wrapper.namespace, content)
+ // A budget fallback still calls the untouched wrapper with already evaluated arguments.
+ for ((parameter, value) in temporaries) {
+ if (call.arguments[parameter] != null) call.arguments[parameter] = value.deepCopyWithSymbols()
+ }
+ +call
+ }.let { block ->
+ block.patchDeclarationParents(builder.scope.scopeOwnerSymbol.owner as IrDeclarationParent)
+ if (block.statements.size == 1) block.statements.single() as IrExpression else block
+ }
+ }
+
+ // Recognize marked wrappers, Input and direct TagElement calls with supported arguments.
+ private fun recognize(call: IrCall): Wrapper? {
+ val function = call.symbol.owner
+ val marker = function.annotations.singleOrNull { it.classSymbol == symbols.tagMarker }
+ val input = call.symbol in symbols.inputs
+ val direct = function.fqNameWhenAvailable in HtmlSymbols.DirectTags
+
+ if (marker == null && !input && !direct) return null
+ val receiver = function.parameters.firstOrNull { it.kind == IrParameterKind.ExtensionReceiver }
+ if (receiver != null && call.arguments[receiver] !is IrGetValue) return null
+ val attrs = function.parameter(if (direct) "applyAttrs" else "attrs")
+ if (attrs == null && !input) return null
+ val attrsArgument = call.argument(attrs)
+ if (!attrsArgument.isAbsent() && attrsArgument !is IrFunctionExpression) return null
+
+ val spec = TagSpec(marker)
+ val tag = when {
+ direct -> directTag(call) ?: return null
+ input -> "input"
+ else -> spec.tagName.ifEmpty { function.name.asString().lowercase() }
+ }
+ val namespaceParameter = function.parameter("namespace").takeIf { direct }
+ val namespace =
+ if (namespaceParameter != null) constants.string(call.arguments[namespaceParameter]) ?: return null
+ else spec.namespace
+ // foreignObject switches its children from SVG to HTML; leave that boundary to runtime rendering.
+ if (!isValidHtmlTagName(tag) || tag == "foreignObject") return null
+ val text = spec.text.takeIf { it.isNotEmpty() }?.let(function::parameter)
+ if (text != null && call.arguments[text] == null) return null
+ val content = function.parameter("content").takeIf { it != text }
+ val inputType = function.parameter("type").takeIf { input }
+ val positional = if (direct || input) emptyList() else function.parameters.filter {
+ it.kind == IrParameterKind.Regular && it != attrs && it != content && it != text
+ }
+
+ return Wrapper(call, spec, tag, namespace, attrs, text, content, inputType, positional)
+ }
+
+ private fun directTag(call: IrCall): String? {
+ val function = call.symbol.owner
+ function.parameter("tagName")?.let { return constants.string(call.arguments[it]) }
+ val factory = call.argument(function.parameter("elementBuilder")) as? IrCall ?: return null
+ if (factory.symbol.owner.fqNameWhenAvailable != HtmlSymbols.CreateBuilder ||
+ factory.symbol.owner.parameters.count { it.kind == IrParameterKind.Regular } != 1
+ ) return null
+
+ return constants.string(factory.arguments.lastOrNull())
+ }
+
+ // Fill omitted arguments from marker metadata or null. Leave unsupported defaults at runtime.
+ private fun defaults(wrapper: Wrapper, builder: DeclarationIrBuilder): Map? {
+ val defaults = mutableMapOf()
+ for (parameter in wrapper.hoisted) {
+ if (wrapper.call.arguments[parameter] != null) continue
+ val value = wrapper.spec.defaults[parameter.name.asString()]
+ defaults[parameter] = if (value != null) {
+ when {
+ parameter.type.isString() -> builder.irString(value)
+ parameter.type.isBoolean() -> builder.irBoolean(value.toBooleanStrictOrNull() ?: return null)
+ value.toIntOrNull() != null -> builder.irInt(value.toInt())
+ else -> return null
+ }
+ } else if (parameter.type.isNullable()) builder.irNull(parameter.type) else return null
+ }
+ return defaults
+ }
+
+ private fun IrBlockBuilder.hoistArguments(
+ wrapper: Wrapper,
+ defaults: Map,
+ ): Map = wrapper.hoisted.associateWith { parameter ->
+ val value = wrapper.call.arguments[parameter] ?: defaults.getValue(parameter)
+ if (value is IrConst || constants.string(value) != null || constants.integer(value) != null) value
+ else irGet(irTemporary(value, "html_${parameter.name}"))
+ }
+
+ // Merge wrapper parameters with the attrs body in the order defined by the wrapper metadata.
+ private fun wrapperAttributes(
+ wrapper: Wrapper,
+ values: Map,
+ builder: DeclarationIrBuilder,
+ ): HtmlAttributes? = with(builder) {
+ val before = mutableListOf()
+ val after = mutableListOf()
+ val typeName = wrapper.spec.inputType.takeIf { it.isNotEmpty() }
+ val typeValue = wrapper.inputType?.let { values.getValue(it) }
+ val inputType = typeValue?.let { irCallWithArgs(symbols.inputTypeValue, it) }
+ ?: typeName?.let(::irString)
+ if (inputType != null) before.add(
+ AttributeOperation(
+ AttributeOperationKind.Attribute,
+ null,
+ "type",
+ listOf(inputType)
+ )
+ )
+ val attrsType = wrapper.attrs?.type?.makeNotNull() as? IrSimpleType
+ val attrsReceiver = attrsType?.arguments?.firstOrNull()?.typeOrNull
+ val valueIsAttribute =
+ typeValue?.let { irCallWithArgs(symbols.inputValueIsAttribute, it.deepCopyWithSymbols()) }
+ ?: typeName?.let { irBoolean(it in HtmlSymbols.ValueAttributeInputTypes) }
+ for (parameter in wrapper.positional) {
+ val name = parameter.name.asString()
+ val value = values.getValue(parameter)
+ val operation = analyzer.wrapperOperation(
+ name = name,
+ value = value,
+ namespace = wrapper.namespace,
+ builder = builder,
+ parameterType = parameter.type,
+ receiver = attrsReceiver ?: return null,
+ tag = wrapper.tag,
+ valueIsAttribute = valueIsAttribute,
+ ) ?: return null
+ if (value.isAbsent()) continue
+
+ val condition = when {
+ name in wrapper.spec.omitEmpty -> when (constants.string(value)) {
+ "" -> continue
+ null -> irNotEquals(value.deepCopyWithSymbols(), irString(""))
+ else -> null
+ }
+
+ parameter.type.isBoolean() && operation.values.isEmpty() -> when ((value as? IrConst)?.value) {
+ false -> continue
+ true -> null
+ else -> value.deepCopyWithSymbols()
+ }
+
+ else -> null
+ }
+ val present = if (value.type.isNullable()) irNotEquals(value.deepCopyWithSymbols(), irNull()) else null
+ operation.condition = if (present != null && condition != null) {
+ irIfThenElse(context.irBuiltIns.booleanType, present, condition, irFalse())
+ } else present ?: condition
+ (if (name in wrapper.spec.afterAttrs) after else before).add(operation)
+ }
+ analyzer.analyze(
+ expression = wrapper.call.argument(wrapper.attrs),
+ tag = wrapper.tag,
+ before = before,
+ after = after,
+ namespace = wrapper.namespace,
+ valueIsAttribute = valueIsAttribute,
+ )
+ }
+
+ private fun textContent(value: IrExpression, builder: DeclarationIrBuilder): IrFunctionExpression {
+ val type = context.irBuiltIns.functionN(0).typeWith(context.irBuiltIns.unitType)
+ return builder.irLambda(context, type) {
+ body = DeclarationIrBuilder(context, symbol).irBlockBody {
+ +irCallWithArgs(symbols.text, value.deepCopyWithSymbols())
+ }
+ }
+ }
+}
+
+private fun IrFunction.parameter(name: String): IrValueParameter? =
+ parameters.firstOrNull { it.name.asString() == name }
+
+private fun IrCall.argument(parameter: IrValueParameter?): IrExpression? = parameter?.let { arguments[it] }
diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/WriterScopeSharing.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/WriterScopeSharing.kt
new file mode 100644
index 0000000000..88519daa6b
--- /dev/null
+++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/WriterScopeSharing.kt
@@ -0,0 +1,222 @@
+/*
+ * 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.html.compiler.lowering
+
+import org.jetbrains.compose.html.compiler.HtmlSymbols
+import org.jetbrains.compose.html.compiler.ir.KotlinRepeat
+import org.jetbrains.compose.html.compiler.ir.hasComposableCall
+import org.jetbrains.compose.html.compiler.ir.irCallWithArgs
+import org.jetbrains.kotlin.backend.common.IrElementTransformerVoidWithContext
+import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
+import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder
+import org.jetbrains.kotlin.ir.IrElement
+import org.jetbrains.kotlin.ir.builders.IrBlockBuilder
+import org.jetbrains.kotlin.ir.builders.irBlock
+import org.jetbrains.kotlin.ir.builders.irCall
+import org.jetbrains.kotlin.ir.builders.irGet
+import org.jetbrains.kotlin.ir.builders.irTemporary
+import org.jetbrains.kotlin.ir.declarations.IrFunction
+import org.jetbrains.kotlin.ir.declarations.IrVariable
+import org.jetbrains.kotlin.ir.expressions.IrBlock
+import org.jetbrains.kotlin.ir.expressions.IrCall
+import org.jetbrains.kotlin.ir.expressions.IrExpression
+import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression
+import org.jetbrains.kotlin.ir.expressions.IrGetValue
+import org.jetbrains.kotlin.ir.expressions.IrLoop
+import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin
+import org.jetbrains.kotlin.ir.symbols.IrValueSymbol
+import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI
+import org.jetbrains.kotlin.ir.types.IrType
+import org.jetbrains.kotlin.ir.util.fqNameWhenAvailable
+import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid
+import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid
+import org.jetbrains.kotlin.ir.visitors.acceptVoid
+import org.jetbrains.kotlin.name.FqName
+
+/**
+ * Reuses writer and validation lookups across nested writes and supported loops.
+ * Replaces `Text` calls when their value does not call composable code.
+ *
+ * ```kotlin
+ * for (name in names) {
+ * Text(name)
+ * }
+ * ```
+ *
+ * Becomes, in simplified Kotlin:
+ * ```kotlin
+ * val writer = currentHtmlStringWriter()
+ * val validate = currentHtmlValidationIsStrict()
+ * for (name in names) {
+ * writeText(writer, name, validate)
+ * }
+ * ```
+ *
+ * Ordinary functions and callbacks keep their own scope. Supported eager loop lambdas
+ * such as `repeat` and `forEach` can share the enclosing scope.
+ */
+@OptIn(UnsafeDuringIrConstructionAPI::class)
+internal class WriterScopeSharing(
+ private val context: IrPluginContext,
+ private val symbols: HtmlSymbols,
+) : IrElementTransformerVoidWithContext() {
+ private var writerScope: WriterScopeOrigin? = null
+ private val aliases = mutableMapOf()
+ private val loopFunctions = mutableSetOf()
+
+ override fun visitFunctionNew(declaration: IrFunction): IrFunction {
+ val previous = writerScope
+ if (declaration !in loopFunctions) writerScope = null
+ return try {
+ super.visitFunctionNew(declaration) as IrFunction
+ } finally {
+ writerScope = previous
+ }
+ }
+
+ override fun visitBlock(expression: IrBlock): IrExpression {
+ // Compose expects the FOR_LOOP block to end in its original while loop.
+ if (expression.origin == IrStatementOrigin.FOR_LOOP) {
+ return withLoopScope(expression) { super.visitBlock(expression) }
+ }
+ val scope = expression.origin as? WriterScopeOrigin ?: return super.visitBlock(expression)
+ val writer = scope.writer
+ val validate = scope.validate
+ val previous = writerScope
+ if (previous == null) {
+ writerScope = scope
+ } else {
+ aliases[writer.symbol] = previous.writer
+ aliases[validate.symbol] = previous.validate
+ expression.statements.removeAll { it === writer || it === validate }
+ }
+ return try {
+ super.visitBlock(expression)
+ } finally {
+ writerScope = previous
+ aliases.remove(writer.symbol)
+ aliases.remove(validate.symbol)
+ }
+ }
+
+ override fun visitGetValue(expression: IrGetValue): IrExpression =
+ aliases[expression.symbol]?.let { builder(expression).irGet(it) } ?: super.visitGetValue(expression)
+
+ override fun visitLoop(loop: IrLoop): IrExpression = withLoopScope(loop) { super.visitLoop(loop) }
+
+ override fun visitCall(expression: IrCall): IrExpression {
+ val scope = writerScope
+ if (scope != null && symbols.isPlainText(expression)) {
+ val visited = super.visitCall(expression) as IrCall
+ val builder = builder(expression)
+ return builder.irCallWithArgs(
+ symbols.writeText,
+ builder.irGet(scope.writer), visited.arguments.single(), builder.irGet(scope.validate)
+ )
+ }
+ if (!isEagerLoop(expression)) return super.visitCall(expression)
+ val functions = expression.arguments.filterIsInstance().map { it.function }
+ loopFunctions.addAll(functions)
+ return try {
+ withLoopScope(expression) { super.visitCall(expression) }
+ } finally {
+ loopFunctions.removeAll(functions.toSet())
+ }
+ }
+
+ // Reuse an outer scope, or create one outside a loop that contains direct writes.
+ private inline fun withLoopScope(expression: IrExpression, visit: () -> IrExpression): IrExpression {
+ if (writerScope != null || !containsDirectWrites(expression)) return visit()
+ val builder = builder(expression)
+ return builder.irWriterScope(symbols, expression.type) { writer, validate ->
+ writerScope = WriterScopeOrigin(writer, validate)
+ try {
+ +visit()
+ } finally {
+ writerScope = null
+ }
+ }
+ }
+
+ // Look inside supported eager loops, skipping other function and callback bodies.
+ private fun containsDirectWrites(expression: IrExpression): Boolean {
+ var found = false
+ expression.acceptVoid(object : IrVisitorVoid() {
+ override fun visitElement(element: IrElement) {
+ if (!found) element.acceptChildrenVoid(this)
+ }
+
+ override fun visitFunction(declaration: IrFunction) {
+ if (declaration in loopFunctions) super.visitFunction(declaration)
+ }
+
+ override fun visitCall(expression: IrCall) {
+ if (expression.symbol == symbols.openLoweredRegion || expression.symbol == symbols.beginMixedElement ||
+ symbols.isPlainText(expression)
+ ) found = true
+ if (isEagerLoop(expression)) {
+ // Descend through nested eager loops, but never stored or arbitrary callbacks.
+ expression.arguments.filterIsInstance().forEach {
+ it.function.body?.acceptVoid(this)
+ }
+ } else {
+ super.visitCall(expression)
+ }
+ }
+ })
+ return found
+ }
+
+ private fun builder(expression: IrExpression) = DeclarationIrBuilder(
+ context, requireNotNull(currentScope).scope.scopeOwnerSymbol,
+ expression.startOffset, expression.endOffset,
+ )
+
+ private fun isEagerLoop(call: IrCall): Boolean {
+ val function = call.symbol.owner
+ if (!function.isInline) return false
+ return function.fqNameWhenAvailable in EagerLoops
+ }
+
+ private companion object {
+ val EagerLoops = setOf(
+ KotlinRepeat,
+ FqName("kotlin.collections.forEach"),
+ FqName("kotlin.collections.forEachIndexed"),
+ FqName("kotlin.sequences.forEach"),
+ FqName("kotlin.sequences.forEachIndexed"),
+ FqName("kotlin.text.forEach"),
+ FqName("kotlin.text.forEachIndexed"),
+ )
+ }
+}
+
+// Marks writer and validation locals so the sharing pass can reuse an outer pair.
+internal class WriterScopeOrigin(val writer: IrVariable, val validate: IrVariable) : IrStatementOrigin {
+ override val debugName = "HTML_WRITER_SCOPE"
+ override fun toString(): String = debugName
+}
+
+// Read the writer and validation setting once and mark the block for the sharing pass.
+internal inline fun DeclarationIrBuilder.irWriterScope(
+ symbols: HtmlSymbols,
+ resultType: IrType,
+ body: IrBlockBuilder.(writer: IrVariable, validate: IrVariable) -> Unit,
+): IrExpression {
+ lateinit var scope: WriterScopeOrigin
+ val block = irBlock(resultType = resultType) {
+ val writer = irTemporary(irCall(symbols.currentHtmlStringWriter), "htmlWriter")
+ val validate = irTemporary(irCall(symbols.currentHtmlValidationIsStrict), "htmlValidate")
+ scope = WriterScopeOrigin(writer, validate)
+ body(writer, validate)
+ }
+ block.origin = scope
+ return block
+}
+
+// Sharing a writer is safe only when evaluating the text does not call composable code.
+internal fun HtmlSymbols.isPlainText(call: IrCall): Boolean =
+ call.symbol == text && !hasComposableCall(call.arguments.singleOrNull())
diff --git a/html/eap/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor b/html/eap/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor
new file mode 100644
index 0000000000..247ad1d8eb
--- /dev/null
+++ b/html/eap/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor
@@ -0,0 +1 @@
+org.jetbrains.compose.html.compiler.HtmlCommandLineProcessor
diff --git a/html/eap/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar b/html/eap/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
new file mode 100644
index 0000000000..683c4d3a5b
--- /dev/null
+++ b/html/eap/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
@@ -0,0 +1 @@
+org.jetbrains.compose.html.compiler.HtmlCompilerPluginRegistrar
diff --git a/html/eap/compiler-plugin/tests/build.gradle.kts b/html/eap/compiler-plugin/tests/build.gradle.kts
new file mode 100644
index 0000000000..898f81f749
--- /dev/null
+++ b/html/eap/compiler-plugin/tests/build.gradle.kts
@@ -0,0 +1,71 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
+
+import java.util.Properties
+import org.gradle.api.tasks.bundling.Jar
+import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest
+
+plugins {
+ kotlin("multiplatform")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.compose.html.lowering")
+}
+
+val compilerProject = project(":compose-html-compiler-plugin")
+val compilerJar = compilerProject.tasks.named("jar").flatMap { it.archiveFile }
+val compilerPluginClasspath = files(compilerJar, compilerProject.configurations.named("runtimeClasspath"))
+
+val composeVersion = providers.gradleProperty("compose.version").get()
+val browserVersion = providers.gradleProperty("compose.html.eap.kotlinx-browser-common-subset.version").get()
+val kotlinVersion = Properties().apply {
+ rootProject.file("buildSrc/gradle.properties").inputStream().use(::load)
+}.getProperty("kotlin.version")
+
+kotlin {
+ jvm { compilerOptions { freeCompilerArgs.add("-Xverify-ir=error") } }
+ js { browser { testTask { useKarma { useChromeHeadless() } } } }
+ wasmJs { browser { testTask { useKarma { useChromeHeadless() } } } }
+ applyDefaultHierarchyTemplate()
+
+ sourceSets {
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
+ implementation(project(":html-core-eap"))
+ implementation(project(":html-svg-eap"))
+ implementation("org.jetbrains.compose.html:kotlinx-browser-common-subset:$browserVersion")
+ }
+ jvmTest.dependencies {
+ implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:$kotlinVersion")
+ implementation("org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:$kotlinVersion")
+ }
+ val webTest by getting {
+ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0-RC") }
+ }
+ }
+}
+
+// The comparison suites compile their own samples with lowering both enabled and disabled.
+tasks.withType().configureEach {
+ jvmArgs("-Xss2m") // needed for nested tests to pass on workflow
+ inputs.files(compilerPluginClasspath)
+ doFirst {
+ systemProperty(
+ "htmlLowering.pluginClasspath",
+ compilerPluginClasspath.files.joinToString(",") { it.absolutePath })
+ }
+}
+
+val jvmTest = kotlin.targets.getByName("jvm").compilations.getByName("test")
+val generateHydrationFixture = tasks.register("generateHydrationFixture") {
+ val generatedSources = layout.buildDirectory.dir("generated/hydration")
+ mainClass.set("org.jetbrains.compose.html.compiler.GenerateFixtureKt")
+ classpath(jvmTest.output.allOutputs, jvmTest.runtimeDependencyFiles)
+ args(generatedSources.get().asFile.absolutePath)
+ outputs.dir(generatedSources)
+}
+kotlin.sourceSets.named("webTest") { kotlin.srcDir(generateHydrationFixture) }
diff --git a/html/eap/compiler-plugin/tests/src/commonTest/kotlin/org/jetbrains/compose/html/compiler/CompilerHydration.kt b/html/eap/compiler-plugin/tests/src/commonTest/kotlin/org/jetbrains/compose/html/compiler/CompilerHydration.kt
new file mode 100644
index 0000000000..a377a9ae59
--- /dev/null
+++ b/html/eap/compiler-plugin/tests/src/commonTest/kotlin/org/jetbrains/compose/html/compiler/CompilerHydration.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.html.compiler
+
+import androidx.compose.runtime.Composable
+import org.jetbrains.compose.web.dom.*
+
+@Composable
+fun CompilerHydrationContent(count: Int, increment: () -> Unit) {
+ Div {
+ Span(attrs = { id("compiler-title") }) {
+ Text("Compiler & hydration")
+ Text("")
+ Text(" ready")
+ }
+ ConstantTable()
+ }
+ Button(attrs = { onClick { increment() } }) { Text("Increment") }
+ Span(attrs = { id("compiler-count") }) { Text("Count: $count") }
+}
+
+@Composable
+fun ConstantTable() {
+ Table(attrs = { id("constant-table") }) {
+ Caption {
+ val label = "Results"
+ Text(label)
+ }
+ Colgroup { Col(attrs = { attr("span", "2") }) }
+ Thead { Tr { Th { Text("Name") } } }
+ Tbody {
+ Text("\n")
+ repeat(2) {
+ Tr {
+ Td(attrs = { attr("title", "<&\"") }) {
+ Text("a<&")
+ Text("")
+ Text("b")
+ }
+ Td { Table { Tbody { Tr { Td { Text("nested") } } } } }
+ }
+ }
+ }
+ Tfoot { Tr { Td { Text("Total") } } }
+ }
+}
+
+@Composable
+fun NestedConstantTable() {
+ Div {
+ Text("before")
+ Table { Tbody { Tr { Td { Text("cell") } } } }
+ Text("after")
+ }
+}
+
+@Composable
+fun EmptyConstantTable() {
+ Table {
+ Caption()
+ Colgroup()
+ Thead()
+ Tbody()
+ Tfoot()
+ }
+}
diff --git a/html/eap/compiler-plugin/tests/src/commonTest/kotlin/org/jetbrains/compose/html/compiler/PreparedHydration.kt b/html/eap/compiler-plugin/tests/src/commonTest/kotlin/org/jetbrains/compose/html/compiler/PreparedHydration.kt
new file mode 100644
index 0000000000..a39d5b06c4
--- /dev/null
+++ b/html/eap/compiler-plugin/tests/src/commonTest/kotlin/org/jetbrains/compose/html/compiler/PreparedHydration.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.html.compiler
+
+import androidx.compose.runtime.Composable
+import kotlinx.browser.dom.HTMLDivElement
+import kotlinx.browser.dom.HTMLSpanElement
+import org.jetbrains.compose.web.dom.Button
+import org.jetbrains.compose.web.dom.ContentBuilder
+import org.jetbrains.compose.web.dom.Div
+import org.jetbrains.compose.web.dom.ElementScope
+import org.jetbrains.compose.web.dom.Span
+import org.jetbrains.compose.web.dom.Text
+
+@Composable
+fun PreparedHydrationContent(
+ count: Int,
+ increment: () -> Unit,
+ inspect: @Composable ElementScope.(ElementScope) -> Unit = {},
+) {
+ val content: ContentBuilder = {
+ Div(attrs = { classes("card") }) card@{
+ Text("User $count")
+ PreparedDetails(count)
+ Span(attrs = {
+ id("prepared-price")
+ classes("price")
+ }) {
+ Text(scopedPrice(count, this@card, inspect))
+ }
+ Span(attrs = { id("prepared-literal") }) { Text("<& literal") }
+ Span(attrs = { classes("shared-literal") }) { Text("<& literal") }
+ Span(attrs = { classes("shared-literal") }) { Text("<& literal") }
+ Div(attrs = { classes("shared-fragment") }) { Span { Text("<& fragment") } }
+ Div(attrs = { classes("shared-fragment") }) { Span { Text("<& fragment") } }
+ }
+ }
+ Div(attrs = { id("prepared-root") }, content = content)
+ Button(attrs = {
+ id("prepared-increment")
+ onClick { increment() }
+ }) { Text("Increment") }
+}
+
+@Composable
+private fun PreparedDetails(count: Int) {
+ Div(attrs = { classes("direct-details"); attr("data-count", count.toString()) }) {
+ Span { Text("prefix") }
+ Span(attrs = { classes("details") }) { Text("Details $count") }
+ repeat(2) { index ->
+ Span(attrs = { classes("loop-details") }) { Text("Loop $index: $count") }
+ }
+ Span { Text("suffix") }
+ }
+}
+
+@Composable
+private fun ElementScope.scopedPrice(
+ count: Int,
+ outer: ElementScope,
+ inspect: @Composable ElementScope.(ElementScope) -> Unit,
+): String {
+ inspect(outer)
+ return "Price: $count"
+}
diff --git a/html/eap/compiler-plugin/tests/src/commonTest/kotlin/org/jetbrains/compose/html/compiler/samples/LargeFragment.kt b/html/eap/compiler-plugin/tests/src/commonTest/kotlin/org/jetbrains/compose/html/compiler/samples/LargeFragment.kt
new file mode 100644
index 0000000000..60d014768f
--- /dev/null
+++ b/html/eap/compiler-plugin/tests/src/commonTest/kotlin/org/jetbrains/compose/html/compiler/samples/LargeFragment.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.html.compiler.samples
+
+import androidx.compose.runtime.Composable
+import org.jetbrains.compose.web.dom.*
+
+private const val HundredAscii =
+ "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+private const val ThousandAscii = HundredAscii + HundredAscii + HundredAscii + HundredAscii + HundredAscii +
+ HundredAscii + HundredAscii + HundredAscii + HundredAscii + HundredAscii
+private const val BoundaryPadding = ThousandAscii + ThousandAscii + ThousandAscii + ThousandAscii +
+ ThousandAscii + ThousandAscii + ThousandAscii + HundredAscii + HundredAscii + HundredAscii +
+ HundredAscii + HundredAscii + HundredAscii + HundredAscii + HundredAscii + HundredAscii +
+ "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+private const val HundredWide =
+ "界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界"
+private const val ThousandWide = HundredWide + HundredWide + HundredWide + HundredWide + HundredWide +
+ HundredWide + HundredWide + HundredWide + HundredWide + HundredWide
+private const val WideText = ThousandWide + ThousandWide + ThousandWide
+
+@Composable
+fun LargeUnicodeFragment() {
+ Div {
+ Text(BoundaryPadding + "😀<&\r")
+ Text("")
+ Text("tail")
+ Span { Text("child") }
+ }
+}
+
+@Composable
+fun LargeStaticPage() {
+ Html { Body { Div { repeat(30) { Span { Text(WideText) } } } } }
+}
+
+@Composable
+fun LargeRawFragment() {
+ Iframe {
+ Text(BoundaryPadding + "\r")
+ Text("\n😀\u0000<&")
+ }
+}
+
+@Composable
+fun InvalidLargeRawFragment() {
+ Iframe {
+ Text(BoundaryPadding + "")
+ }
+}
+
+@Composable
+fun LargeLeadingNewlineFragment() {
+ Pre { Text("\n" + BoundaryPadding + "😀") }
+}
+
+@Composable
+fun LargeTableFragment() {
+ Table { Tbody { Tr { Td { Text(BoundaryPadding + "😀") } } } }
+}
+
+@Composable
+fun LargeTableRowFragment() {
+ Tr { Td { Text(BoundaryPadding + "😀") } }
+}
+
+@Composable
+fun LargeStaticHydration(count: Int, increment: () -> Unit) {
+ LargeUnicodeFragment()
+ Button(attrs = { id("large-increment"); onClick { increment() } }) { Text("Increment") }
+ Span(attrs = { id("large-count") }) { Text("Count: $count") }
+}
diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/ControlFlowLoweringTest.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/ControlFlowLoweringTest.kt
new file mode 100644
index 0000000000..0afd8a7949
--- /dev/null
+++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/ControlFlowLoweringTest.kt
@@ -0,0 +1,267 @@
+/*
+ * 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.html.compiler
+
+import androidx.compose.runtime.Composable
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.jetbrains.compose.html.compiler.samples.ConstantRepeat
+import org.jetbrains.compose.html.compiler.samples.EffectfulRepeat
+import org.jetbrains.compose.html.compiler.samples.EmptyConstantRepeats
+import org.jetbrains.compose.html.compiler.samples.IndexedRepeat
+import org.jetbrains.compose.html.compiler.samples.LargeRepeat
+import org.jetbrains.compose.html.compiler.samples.LoopCallbacks
+import org.jetbrains.compose.html.compiler.samples.LoopRows
+import org.jetbrains.compose.html.compiler.samples.LoopTextBoundaries
+import org.jetbrains.compose.html.compiler.samples.LoopValidationScope
+import org.jetbrains.compose.html.compiler.samples.NestedLoops
+import org.jetbrains.compose.html.compiler.samples.ShadowedRepeat
+import org.jetbrains.compose.html.compiler.samples.TopLevelForEach
+import org.jetbrains.compose.html.compiler.samples.TopLevelLoop
+import org.jetbrains.compose.html.compiler.samples.TopLevelLoopText
+import org.jetbrains.compose.web.composeHtmlToString
+import org.jetbrains.org.objectweb.asm.ClassVisitor
+import org.jetbrains.org.objectweb.asm.Label
+import org.jetbrains.org.objectweb.asm.MethodVisitor
+import org.jetbrains.org.objectweb.asm.Opcodes
+
+class ControlFlowLoweringTest {
+ @Test
+ fun loopsReadWriterAndValidationOnce() {
+ for (method in listOf("LoopRows", "TopLevelLoop", "TopLevelForEach", "TopLevelLoopText", "NestedLoops")) {
+ val calls = mutableListOf()
+ val labels = mutableMapOf