diff --git a/.github/workflows/compose-html-eap.yml b/.github/workflows/compose-html-eap.yml index 00c5cd3f01..8d4a2f5a7c 100644 --- a/.github/workflows/compose-html-eap.yml +++ b/.github/workflows/compose-html-eap.yml @@ -10,6 +10,7 @@ on: - 'html/settings.gradle.kts' - 'html/gradle.properties' - 'html/buildSrc/**' + - 'html/gradle/**' - '.github/workflows/compose-html-eap.yml' push: branches: @@ -22,6 +23,7 @@ on: - 'html/settings.gradle.kts' - 'html/gradle.properties' - 'html/buildSrc/**' + - 'html/gradle/**' - '.github/workflows/compose-html-eap.yml' jobs: @@ -50,6 +52,14 @@ jobs: :html-core-eap:check \ :html-svg-eap:check \ :internal-html-core-runtime-eap:check \ + :internal-html-serialization-eap:check \ :html-core-eap:publishToMavenLocal \ :html-svg-eap:publishToMavenLocal \ - :internal-html-core-runtime-eap:publishToMavenLocal + :internal-html-core-runtime-eap:publishToMavenLocal \ + :internal-html-serialization-eap:publishToMavenLocal + + - name: Test HTML compiler and Gradle plugins, including browser hydration + working-directory: html + run: | + ./gradlew --no-daemon --stacktrace --max-workers=1 \ + -Pcompose.html.eap.enabled=true :compose-html-compiler-plugin:check diff --git a/html/build.gradle.kts b/html/build.gradle.kts index 917219c90e..846eb77992 100644 --- a/html/build.gradle.kts +++ b/html/build.gradle.kts @@ -17,6 +17,7 @@ val localProperties = java.util.Properties().apply { file.inputStream().use(::load) } } + fun setting(name: String): String? = localProperties.getProperty(name) ?: providers.gradleProperty(name).orNull @@ -32,11 +33,12 @@ kotlinKarmaConfig.rootDir = rootProject.rootDir.toString() apply() -fun Project.isSampleProject() = projectDir.parentFile.name == "examples" +// Only the browser samples have bundle-size tasks; EAP examples also include JVM-only projects. +fun Project.isSampleProject() = path.startsWith(":examples:") tasks.register("printBundleSize") { dependsOn( - subprojects.filter { it.isSampleProject() }.map { ":examples:${it.name}:printBundleSize" } + subprojects.filter { it.isSampleProject() }.map { "${it.path}:printBundleSize" } ) } @@ -48,6 +50,7 @@ subprojects { "html-core-eap", "html-svg-eap", "internal-html-core-runtime-eap", + "internal-html-serialization-eap", ) if (isComposeHtmlEapProject) { group = "org.jetbrains.compose.html.eap" @@ -90,7 +93,10 @@ subprojects { repositories { maven { name = "internal" - url = uri(COMPOSE_REPO_URL ?: System.getenv("COMPOSE_REPO_URL") ?: "https://packages.jetbrains.team/maven/p/cmp/dev") + url = uri( + COMPOSE_REPO_URL ?: System.getenv("COMPOSE_REPO_URL") + ?: "https://packages.jetbrains.team/maven/p/cmp/dev" + ) credentials { username = COMPOSE_REPO_USERNAME ?: System.getenv("COMPOSE_REPO_USERNAME") ?: "" password = COMPOSE_REPO_KEY ?: System.getenv("COMPOSE_REPO_KEY") ?: "" @@ -106,6 +112,10 @@ subprojects { "html-svg-eap" -> "JetBrains Compose HTML EAP SVG" "internal-html-core-runtime-eap" -> "JetBrains Compose HTML EAP internal runtime" + + "internal-html-serialization-eap" -> + "JetBrains Compose HTML EAP internal serialization" + else -> "JetBrains Compose HTML library" } ) @@ -113,10 +123,16 @@ subprojects { when (projectName) { "html-core-eap" -> "Experimental commonized JetBrains Compose HTML core library" + "html-svg-eap" -> "Experimental commonized JetBrains Compose HTML SVG library" + "internal-html-core-runtime-eap" -> "Experimental commonized JetBrains Compose HTML internal runtime" + + "internal-html-serialization-eap" -> + "Shared internal serialization rules for JetBrains Compose HTML EAP and its compiler plugin" + else -> "JetBrains Compose HTML library" } ) diff --git a/html/eap/README.md b/html/eap/README.md index 18bd6deba3..7ce2161bb0 100644 --- a/html/eap/README.md +++ b/html/eap/README.md @@ -21,11 +21,11 @@ Fast validation is the default. Set `COMPOSE_HTML_VALIDATE_STRICTLY=true` on the with strict validation. Browser hydration follows the server's mode. Direct callers can pass `validateStrictly = true` to `hydrateComposable`. -| During hydration | Fast (default) | Strict | -| --- | --- | --- | +| During hydration | Fast (default) | Strict | +|------------------------------------|--------------------------------------------|----------------------------------------------| | Text, attributes, or styles differ | Keeps server values until a Compose update | Reports a mismatch and renders on the client | -| `allowHydrationMismatch()` | Applies client values | Applies client values | -| Structure differs | Renders on the client | Renders on the client | +| `allowHydrationMismatch()` | Applies client values | Applies client values | +| Structure differs | Renders on the client | Renders on the client | Strict mode also checks tag and attribute names, class tokens, foreign-attribute collisions and table-element nesting. @@ -47,11 +47,19 @@ dependencies { implementation("org.jetbrains.compose.html:kotlinx-browser-common-subset:") } ``` + Local build versions are configured in `html/gradle.properties`. Do not put the regular and EAP Compose HTML artifacts on the same compilation classpath because they intentionally expose the same Kotlin packages and declarations. +## Static HTML lowering + +The HTML compiler plugin prepares static HTML at compile time to reduce work during +server-side rendering on the JVM. Dynamic content is still rendered at runtime. + +See the [plugin README](compiler-plugin/README.md#activate) for activation and configuration. + ## Examples ### String Rendering diff --git a/html/eap/compiler-plugin/README.md b/html/eap/compiler-plugin/README.md new file mode 100644 index 0000000000..cb190abf7e --- /dev/null +++ b/html/eap/compiler-plugin/README.md @@ -0,0 +1,46 @@ +# Compose HTML compiler plugin + +Prepares static HTML at compile time to reduce work during server-side rendering. + +## Activate + +Use the [Compose HTML EAP runtime](../README.md#published-artifacts). +Add the plugin to your module's `build.gradle.kts` alongside your existing Kotlin and Compose compiler plugins: + +```kotlin +plugins { + id("org.jetbrains.compose.html.lowering") version "" +} +``` + +Lowering is enabled for JVM targets. +The Gradle plugin adds the compiler plugin and sets the required compiler order. + +### Using a local checkout + +To resolve both plugins from source, add these entries to your project's `settings.gradle.kts`: + +```kotlin +pluginManagement { + includeBuild("/path/to/html/eap/compiler-plugin/gradle-plugin") + // Keep your existing plugin repositories here. +} + +includeBuild("/path/to/html") +``` + +For the local EAP runtime setup, see the [EAP README](../README.md). + +## Configure + +Optional settings in your module's `build.gradle.kts`: + +```kotlin +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType + +htmlLowering { + enabledTargets.set(setOf(KotlinPlatformType.jvm, KotlinPlatformType.androidJvm)) +} +``` + +Currently, only JVM rendering is supported. Set it to `emptySet()` to disable lowering for all targets. diff --git a/html/eap/compiler-plugin/build.gradle.kts b/html/eap/compiler-plugin/build.gradle.kts new file mode 100644 index 0000000000..36242aa8c5 --- /dev/null +++ b/html/eap/compiler-plugin/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * 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. + */ + +import java.util.Properties + +plugins { + kotlin("jvm") +} + +val pluginProperties = Properties().apply { + file("plugin.properties").inputStream().use(::load) +} +val kotlinVersion = Properties().apply { + rootProject.file("buildSrc/gradle.properties").inputStream().use(::load) +}.getProperty("kotlin.version") + +group = "org.jetbrains.compose.html" +version = pluginProperties.getProperty("version") + +val compilerPluginPath = path +subprojects { + // Resolve the compiler artifact from this build while developing the Gradle plugin. + configurations.configureEach { + resolutionStrategy.dependencySubstitution { + substitute(module("org.jetbrains.compose.html:compose-html-compiler-plugin")) + .using(project(compilerPluginPath)) + } + } +} + +// The HTML root build targets JVM 11. Gradle requires Java to use the same target. +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +dependencies { + implementation(project(":internal-html-serialization-eap")) + compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:$kotlinVersion") +} + +publishing { + publications { + create("maven") { + from(components["java"]) + } + } +} + +tasks.named("check") { + dependsOn( + "$compilerPluginPath:tests:check", + gradle.includedBuild("compose-html-lowering-gradle-plugin").task(":test") + ) +} diff --git a/html/eap/compiler-plugin/gradle-plugin/build.gradle.kts b/html/eap/compiler-plugin/gradle-plugin/build.gradle.kts new file mode 100644 index 0000000000..3175cc4536 --- /dev/null +++ b/html/eap/compiler-plugin/gradle-plugin/build.gradle.kts @@ -0,0 +1,63 @@ +/* + * Copyright 2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + `java-gradle-plugin` +} + +val pluginProperties = Properties().apply { + file("../plugin.properties").inputStream().use(::load) +} +val kotlinVersion = Properties().apply { + file("../../../buildSrc/gradle.properties").inputStream().use(::load) +}.getProperty("kotlin.version") + +group = "org.jetbrains.compose.html" +version = pluginProperties.getProperty("version") + +repositories { + mavenCentral() +} + +// The HTML root build targets JVM 11. Gradle requires Java to use the same target. +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +dependencies { + compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin-api:$kotlinVersion") + testImplementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + testImplementation(kotlin("test-junit")) +} + +gradlePlugin { + plugins { + create("htmlLowering") { + id = pluginProperties.getProperty("id") + implementationClass = "org.jetbrains.compose.html.gradle.HtmlLoweringGradlePlugin" + } + } +} + +tasks.test { + // Gradle ProjectBuilder injects synthetic classes through java.lang on JDK 21. + jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED") +} + +// The plugin needs its coordinates after it has been packaged and installed elsewhere. +tasks.processResources { + from("../plugin.properties") { rename { "compose-html-lowering.properties" } } +} diff --git a/html/eap/compiler-plugin/gradle-plugin/settings.gradle.kts b/html/eap/compiler-plugin/gradle-plugin/settings.gradle.kts new file mode 100644 index 0000000000..a664368916 --- /dev/null +++ b/html/eap/compiler-plugin/gradle-plugin/settings.gradle.kts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +pluginManagement { + val kotlinVersion = java.util.Properties().apply { + file("../../../buildSrc/gradle.properties").inputStream().use(::load) + }.getProperty("kotlin.version") + plugins { + kotlin("jvm") version kotlinVersion + } + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "compose-html-lowering-gradle-plugin" diff --git a/html/eap/compiler-plugin/gradle-plugin/src/main/kotlin/org/jetbrains/compose/html/gradle/HtmlLoweringExtension.kt b/html/eap/compiler-plugin/gradle-plugin/src/main/kotlin/org/jetbrains/compose/html/gradle/HtmlLoweringExtension.kt new file mode 100644 index 0000000000..d755e4bc3f --- /dev/null +++ b/html/eap/compiler-plugin/gradle-plugin/src/main/kotlin/org/jetbrains/compose/html/gradle/HtmlLoweringExtension.kt @@ -0,0 +1,27 @@ +/* + * 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.gradle + +import org.gradle.api.provider.SetProperty +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType + +/** + * Configures HTML lowering for Kotlin compilations. Enabled by default for JVM targets. + * + * ```kotlin + * htmlLowering { + * enabledTargets.set(setOf(KotlinPlatformType.jvm, KotlinPlatformType.androidJvm)) + * } + * ``` + */ +abstract class HtmlLoweringExtension { + /** + * Kotlin platform types for which lowering is enabled. Defaults to [KotlinPlatformType.jvm]. + * Set to an empty set to disable lowering for all targets. + * Enabling an unsupported compiler platform reports a warning and skips lowering. + */ + abstract val enabledTargets: SetProperty +} diff --git a/html/eap/compiler-plugin/gradle-plugin/src/main/kotlin/org/jetbrains/compose/html/gradle/HtmlLoweringGradlePlugin.kt b/html/eap/compiler-plugin/gradle-plugin/src/main/kotlin/org/jetbrains/compose/html/gradle/HtmlLoweringGradlePlugin.kt new file mode 100644 index 0000000000..769b67db58 --- /dev/null +++ b/html/eap/compiler-plugin/gradle-plugin/src/main/kotlin/org/jetbrains/compose/html/gradle/HtmlLoweringGradlePlugin.kt @@ -0,0 +1,56 @@ +/* + * 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.gradle + +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import org.jetbrains.kotlin.gradle.plugin.SubpluginArtifact +import org.jetbrains.kotlin.gradle.plugin.SubpluginOption + +private val pluginProperties = java.util.Properties().apply { + requireNotNull(HtmlLoweringGradlePlugin::class.java.getResourceAsStream("/compose-html-lowering.properties")) { + "Missing Compose HTML lowering plugin metadata" + }.use(::load) +} + +/** Adds the HTML compiler plugin and the `htmlLowering` configuration. */ +class HtmlLoweringGradlePlugin : KotlinCompilerPluginSupportPlugin { + override fun apply(target: Project) { + target.extensions.create("htmlLowering", HtmlLoweringExtension::class.java).apply { + enabledTargets.convention(setOf(KotlinPlatformType.jvm)) + } + } + + override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean = + kotlinCompilation.platformType != KotlinPlatformType.common + + override fun getCompilerPluginId() = pluginProperties.getProperty("id") + + override fun getPluginArtifact() = SubpluginArtifact( + groupId = "org.jetbrains.compose.html", + artifactId = "compose-html-compiler-plugin", + version = pluginProperties.getProperty("version") + ) + + override fun applyToCompilation(kotlinCompilation: KotlinCompilation<*>): Provider> { + val extension = kotlinCompilation.target.project.extensions.getByType(HtmlLoweringExtension::class.java) + val enabled = extension.enabledTargets.map { kotlinCompilation.platformType in it } + val supportsLowering = + kotlinCompilation.platformType in setOf(KotlinPlatformType.jvm, KotlinPlatformType.androidJvm) + kotlinCompilation.compileTaskProvider.configure { + it.compilerOptions.freeCompilerArgs.addAll(enabled.map { lower -> + if (lower && supportsLowering) listOf( + // The lowering must see HTML calls before the Compose compiler rewrites them. + "-Xcompiler-plugin-order=${getCompilerPluginId()}>androidx.compose.compiler.plugins.kotlin" + ) else emptyList() + }) + } + return enabled.map { listOf(SubpluginOption("enabled", it.toString())) } + } +} diff --git a/html/eap/compiler-plugin/gradle-plugin/src/test/kotlin/org/jetbrains/compose/html/gradle/HtmlLoweringGradlePluginTest.kt b/html/eap/compiler-plugin/gradle-plugin/src/test/kotlin/org/jetbrains/compose/html/gradle/HtmlLoweringGradlePluginTest.kt new file mode 100644 index 0000000000..175603c097 --- /dev/null +++ b/html/eap/compiler-plugin/gradle-plugin/src/test/kotlin/org/jetbrains/compose/html/gradle/HtmlLoweringGradlePluginTest.kt @@ -0,0 +1,90 @@ +/* + * 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.gradle + +import org.gradle.testfixtures.ProjectBuilder +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +@OptIn(ExperimentalWasmDsl::class) +class HtmlLoweringGradlePluginTest { + private val project = ProjectBuilder.builder().build().also { + it.pluginManager.apply("org.jetbrains.kotlin.multiplatform") + it.pluginManager.apply("org.jetbrains.compose.html.lowering") + } + private val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java) + private val plugin = project.plugins.getPlugin(HtmlLoweringGradlePlugin::class.java) + private val extension = project.extensions.getByType(HtmlLoweringExtension::class.java) + private val compilations = listOf( + kotlin.jvm("server"), + kotlin.jvm("otherServer"), + kotlin.js("client").apply { browser() }, + kotlin.wasmJs("wasm").apply { browser() } + ).flatMap { it.compilations.toList() } + + @Test + fun onlyJvmTargetsAreEnabledByDefault() { + assertEquals(setOf(KotlinPlatformType.jvm), extension.enabledTargets.get()) + for (compilation in compilations) { + val isJvm = compilation.platformType == KotlinPlatformType.jvm + assertEquals(isJvm, enabled(compilation)) + assertEquals(isJvm, ordered(compilation)) + } + } + + @Test + fun emptySetDisablesEveryTarget() { + extension.enabledTargets.set(emptySet()) + for (compilation in compilations) { + assertEquals(false, enabled(compilation)) + assertFalse(ordered(compilation)) + } + } + + @Test + fun platformSelectionReplacesDefaultsAndRemainsLazy() { + val options = compilations.map { plugin.applyToCompilation(it) } + compilations.forEach { it.compileTaskProvider.get() } + extension.enabledTargets.set(setOf(KotlinPlatformType.js)) + for ((compilation, provider) in compilations.zip(options)) { + assertEquals( + (compilation.platformType == KotlinPlatformType.js).toString(), + provider.get().single().value + ) + assertFalse(ordered(compilation)) + } + extension.enabledTargets.add(KotlinPlatformType.jvm) + for ((compilation, provider) in compilations.zip(options)) { + val isJvm = compilation.platformType == KotlinPlatformType.jvm + assertEquals( + (compilation.platformType != KotlinPlatformType.wasm).toString(), + provider.get().single().value + ) + assertEquals(isJvm, ordered(compilation)) + } + } + + @Test + fun everyPlatformCanBeSelected() { + extension.enabledTargets.set(KotlinPlatformType.entries.toSet()) + for (compilation in compilations) { + assertEquals(true, enabled(compilation)) + assertEquals(compilation.platformType == KotlinPlatformType.jvm, ordered(compilation)) + } + } + + private fun ordered(compilation: KotlinCompilation<*>): Boolean = + "-Xcompiler-plugin-order=org.jetbrains.compose.html.lowering>androidx.compose.compiler.plugins.kotlin" in + compilation.compileTaskProvider.get().compilerOptions.freeCompilerArgs.get() + + private fun enabled(compilation: KotlinCompilation<*>): Boolean = + plugin.applyToCompilation(compilation).get().single().value.toBooleanStrict() +} diff --git a/html/eap/compiler-plugin/plugin.properties b/html/eap/compiler-plugin/plugin.properties new file mode 100644 index 0000000000..942b808d4f --- /dev/null +++ b/html/eap/compiler-plugin/plugin.properties @@ -0,0 +1,2 @@ +id=org.jetbrains.compose.html.lowering +version=0.0.1 diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/HtmlLoweringPlugin.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/HtmlLoweringPlugin.kt new file mode 100644 index 0000000000..747e18beaa --- /dev/null +++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/HtmlLoweringPlugin.kt @@ -0,0 +1,95 @@ +/* + * 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 org.jetbrains.compose.html.compiler.lowering.FileLowering +import org.jetbrains.compose.html.compiler.lowering.WriterScopeSharing +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.compiler.plugin.AbstractCliOption +import org.jetbrains.kotlin.compiler.plugin.CliOption +import org.jetbrains.kotlin.compiler.plugin.CliOptionProcessingException +import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor +import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.config.CompilerConfigurationKey +import org.jetbrains.kotlin.config.MessageCollectorAccess +import org.jetbrains.kotlin.config.messageCollector +import org.jetbrains.kotlin.ir.declarations.IrModuleFragment +import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid +import org.jetbrains.kotlin.platform.jvm.isJvm + +internal const val HtmlCompilerPluginId = "org.jetbrains.compose.html.lowering" + +@OptIn(ExperimentalCompilerApi::class) +class HtmlCompilerPluginRegistrar : CompilerPluginRegistrar() { + override val pluginId = HtmlCompilerPluginId + override val supportsK2 = true + + override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + IrGenerationExtension.registerExtension( + HtmlIrGenerationExtension(configuration) + ) + } +} + +@OptIn(ExperimentalCompilerApi::class) +class HtmlCommandLineProcessor : CommandLineProcessor { + override val pluginId = HtmlCompilerPluginId + override val pluginOptions = listOf( + CliOption("enabled", "true|false", "Enable static HTML lowering (defaults to JVM only)", required = false) + ) + + override fun processOption(option: AbstractCliOption, value: String, configuration: CompilerConfiguration) { + when (option.optionName) { + "enabled" -> configuration.put( + EnabledKey, + value.toBooleanStrictOrNull() + ?: throw CliOptionProcessingException("Expected true or false for enabled, got '$value'") + ) + + else -> throw CliOptionProcessingException("Unknown option: ${option.optionName}") + } + } + + companion object { + internal val EnabledKey = CompilerConfigurationKey("Enable static HTML lowering") + } +} + +// Lowers HTML calls on JVM before the Compose compiler rewrites them. +@OptIn(MessageCollectorAccess::class) +internal class HtmlIrGenerationExtension( + private val configuration: CompilerConfiguration +) : IrGenerationExtension { + override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { + val explicitEnabled = configuration.get(HtmlCommandLineProcessor.EnabledKey) + val enabled = explicitEnabled ?: pluginContext.platform.isJvm() + if (!enabled) return + if (!pluginContext.platform.isJvm()) { + configuration.messageCollector.report( + CompilerMessageSeverity.WARNING, + "[compose-html-lowering] Currently only HTML string rendering on Kotlin/JVM is supported; " + + "skipping lowering for ${pluginContext.platform}." + ) + return + } + + val symbols = HtmlSymbols.resolve(pluginContext.finderForBuiltins()) { names -> + configuration.messageCollector.report( + if (explicitEnabled == true) CompilerMessageSeverity.ERROR else CompilerMessageSeverity.WARNING, + "[compose-html-lowering] Missing or ambiguous runtime declarations: ${names.joinToString()}. Skipping lowering. " + + "Check that the Compose HTML runtime and compiler plugin versions match. " + + "Set enabled=false to disable lowering for this compilation." + ) + } ?: return + // Replace HTML calls first, then reuse writer lookups in the generated code. + for (file in moduleFragment.files) FileLowering(pluginContext, configuration, symbols, file).lower() + moduleFragment.transformChildrenVoid(WriterScopeSharing(pluginContext, symbols)) + } +} diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/HtmlSymbols.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/HtmlSymbols.kt new file mode 100644 index 0000000000..a675709b04 --- /dev/null +++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/HtmlSymbols.kt @@ -0,0 +1,215 @@ +/* + * 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 kotlin.properties.ReadOnlyProperty +import org.jetbrains.compose.html.compiler.ir.withOverrides +import org.jetbrains.kotlin.backend.common.extensions.DeclarationFinder +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrVararg +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.IrSimpleType +import org.jetbrains.kotlin.ir.types.typeOrNull +import org.jetbrains.kotlin.ir.types.classOrNull +import org.jetbrains.kotlin.ir.types.isString +import org.jetbrains.kotlin.ir.util.classId +import org.jetbrains.kotlin.ir.util.fqNameWhenAvailable +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name + +// Finds the runtime classes and functions used by the generated code. +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class HtmlSymbols private constructor(resolver: Resolver) { + private val finder = resolver.finder + val formatNumber by resolver.function(FqName("org.jetbrains.compose.web.internal"), "formatNumber") + val writeStaticHtml by resolver.htmlFunction("WriteStaticHtml") + val beginStaticHtmlFragment by resolver.writerFunction("beginStaticHtmlFragment") + val appendStaticHtmlChunk by resolver.writerFunction("appendStaticHtmlChunk") + val preparedHtmlAttributes by resolver.htmlFunction("preparedHtmlAttributes") + val attributesCallback by resolver.getter(HtmlPackage, "PreparedHtmlAttributes", "callback") + val preparedTextElement by resolver.htmlFunction("PreparedTextElement") + val preparedContainer by resolver.htmlFunction("PreparedContainer") + val currentHtmlStringWriter by resolver.htmlFunction("currentHtmlStringWriter") + val currentHtmlValidationIsStrict by resolver.htmlFunction("currentHtmlValidationIsStrict") + val openLoweredRegion by resolver.writerFunction("openLoweredRegion") + val advanceLoweredRegion by resolver.writerFunction("advanceLoweredRegion") + val writeText by resolver.writerFunction("writeText") + val closeElement by resolver.writerFunction("closeElement") + val beginMixedElement by resolver.writerFunction("beginMixedElement") + val writeMixedLiteral by resolver.writerFunction("writeMixedLiteral") + val writeMixedAttribute by resolver.writerFunction("writeMixedAttribute") + val mixedStyle by resolver.htmlFunction("mixedStyle") + val writeMixedStyle by resolver.writerFunction("writeMixedStyle") + val validateMixedClass by resolver.writerFunction("validateMixedClass") + val writeMixedClass by resolver.writerFunction("writeMixedClass") + val finishMixedClasses by resolver.writerFunction("finishMixedClasses") + val finishMixedElement by resolver.writerFunction("finishMixedElement") + val inputs by resolver.inputs() + val inputTypeValue by resolver.getter(AttributesPackage, "InputType", "typeStr") + val attributeMarker by resolver.htmlClass("LowerableAttribute") + val anyToString by resolver.member(ClassId.topLevel(FqName("kotlin.Any")), "toString") + val autoCompleteValue by resolver.htmlFunction("htmlAutoCompleteValue") + val inputValueIsAttribute by resolver.htmlFunction("inputValueIsAttribute") + + // @LowerableHtmlTag identifies tag wrappers that the plugin may lower. + val tagMarker by resolver.htmlClass("LowerableHtmlTag") + val text by resolver.htmlFunction("Text") + val classes by resolver.attrsFunction("classes", varargOnly = true) + val id by resolver.attrsFunction("id") + val attr by resolver.attrsFunction("attr") + val style by resolver.attrsFunction("style") + + data class Attribute( + val name: String, + val conversion: IrSimpleFunctionSymbol?, + val value: String, + val defaultValue: String? + ) + + private val attributes = mutableMapOf() + private val helpers = mutableMapOf, List>() + + // Reads attribute metadata, including metadata inherited from an overridden helper. + fun attribute(symbol: IrSimpleFunctionSymbol): Attribute? = attributes.getOrPut(symbol) { + val declaration = symbol.withOverrides().firstOrNull { candidate -> + candidate.owner.annotations.count { it.classSymbol == attributeMarker } == 1 + } ?: return@getOrPut null + val marker = declaration.owner.annotations.single { it.classSymbol == attributeMarker } + fun string(name: String) = + (marker.argumentMapping[Name.identifier(name)] as? IrConst)?.value as? String ?: "" + + val property = string("conversion").ifEmpty { + if (declaration.owner.parameters.size == 1 || declaration.owner.parameters.last().type.isString()) "" else "toString" + } + val conversion = when (property) { + "" -> null + "formatNumber" -> formatNumber + "toString" -> anyToString + "onOff" -> autoCompleteValue + else -> { + val owner = declaration.owner.parameters.last().type.classOrNull?.owner?.classId ?: return@getOrPut null + finder.findProperties(CallableId(owner, Name.identifier(property))) + .singleOrNull()?.owner?.getter?.symbol + ?: return@getOrPut null + } + } + val name = string("name").ifEmpty { + val name = declaration.owner.name.asString() + if (declaration.owner.fqNameWhenAvailable?.parent() + ?.asString() == SvgPackage.asString() + ) name else name.lowercase() + } + val defaultValue = (marker.argumentMapping[Name.identifier("defaultValue")] as? IrVararg) + ?.elements?.singleOrNull()?.let { (it as? IrConst)?.value as? String } + Attribute(name, conversion, string("value"), defaultValue) + } + + // Finds scope members and HTML or SVG extensions for a wrapper parameter. + fun helpers(name: String, namespace: String, receiver: IrType): List { + val pkg = if (namespace == SvgNamespace) SvgPackage else AttributesPackage + val extensions = helpers.getOrPut(name to namespace) { + finder.findFunctions(CallableId(pkg, Name.identifier(name))).toList() + } + val owner = receiver.classOrNull?.owner?.classId + val members = owner?.let { finder.findFunctions(CallableId(it, Name.identifier(name))).toList() }.orEmpty() + return members + extensions + } + + companion object { + val HtmlPackage = FqName("org.jetbrains.compose.web.dom") + val AttributesPackage = FqName("org.jetbrains.compose.web.attributes") + val SvgPackage = FqName("org.jetbrains.compose.web.svg") + + // Standard wrappers use compile-time type names, while generic Input uses the runtime identity check. + val ValueAttributeInputTypes = setOf("checkbox", "radio", "hidden", "submit") + const val SvgNamespace = "http://www.w3.org/2000/svg" + const val InputScope = "org.jetbrains.compose.web.attributes.builders.InputAttrsScope" + const val TextAreaScope = "org.jetbrains.compose.web.attributes.builders.TextAreaAttrsScope" + const val SelectScope = "androidx.compose.web.attributes.SelectAttrsScope" + const val EventsScope = "org.jetbrains.compose.web.attributes.EventsListenerScope" + val AttrsScope = ClassId.topLevel(AttributesPackage.child(Name.identifier("AttrsScope"))) + val DirectTags = + setOf(HtmlPackage.child(Name.identifier("TagElement")), HtmlPackage.child(Name.identifier("TagElementNS"))) + val CreateBuilder = FqName("org.jetbrains.compose.web.dom.ElementBuilder.Companion.createBuilder") + val FormEvents = setOf( + AttributesPackage.child(Name.identifier("onSubmit")), + AttributesPackage.child(Name.identifier("onReset")) + ) + + // Returns null and reports all missing or ambiguous declarations if the runtime does not match. + fun resolve(finder: DeclarationFinder, onUnavailable: (List) -> Unit): HtmlSymbols? { + val resolver = Resolver(finder) + val symbols = HtmlSymbols(resolver) + if (resolver.unavailable.isEmpty()) return symbols + onUnavailable(resolver.unavailable) + return null + } + } + + private class Resolver(val finder: DeclarationFinder) { + val unavailable = mutableListOf() + + // resolve checks every required declaration before these values can be read. + fun required(name: String, value: T?): ReadOnlyProperty { + if (value == null) unavailable.add(name) + return ReadOnlyProperty { _, _ -> checkNotNull(value) { "Unresolved runtime declaration: $name" } } + } + + fun function(pkg: FqName, name: String) = required( + "$pkg.$name", finder.findFunctions(CallableId(pkg, Name.identifier(name))).singleOrNull() + ) + + fun htmlFunction(name: String) = function(HtmlPackage, name) + + fun htmlClass(name: String) = required( + "$HtmlPackage.$name", finder.findClass(ClassId.topLevel(HtmlPackage.child(Name.identifier(name)))) + ) + + fun member(owner: ClassId, name: String) = required( + "${owner.asSingleFqName()}.$name", + finder.findFunctions(CallableId(owner, Name.identifier(name))).singleOrNull() + ) + + fun writerFunction(name: String) = + member(ClassId.topLevel(HtmlPackage.child(Name.identifier("HtmlStringWriterContext"))), name) + + fun getter(pkg: FqName, owner: String, name: String) = required( + "$pkg.$owner.$name", finder.findProperties( + CallableId(ClassId.topLevel(pkg.child(Name.identifier(owner))), Name.identifier(name)) + ).singleOrNull()?.owner?.getter?.symbol + ) + + fun attrsFunction(name: String, varargOnly: Boolean = false) = required( + "${AttrsScope.asSingleFqName()}.$name${if (varargOnly) "(vararg)" else ""}", + finder.findFunctions(CallableId(AttrsScope, Name.identifier(name))).singleOrNull { + !varargOnly || it.owner.parameters.any { parameter -> parameter.varargElementType != null } + } + ) + + fun inputs() = required( + "$HtmlPackage.Input(type[, attrs])", + finder.findFunctions(CallableId(HtmlPackage, Name.identifier("Input"))).filter { + val parameters = it.owner.parameters + val names = parameters.map { parameter -> parameter.name.asString() } + val inputType = ClassId.topLevel(AttributesPackage.child(Name.identifier("InputType"))) + if (names !in listOf(listOf("type"), listOf("type", "attrs")) || + parameters.first().type.classOrNull?.owner?.classId != inputType + ) return@filter false + val attrs = parameters.getOrNull(1)?.type as? IrSimpleType + names.size == 1 || ( + attrs?.classOrNull?.owner?.fqNameWhenAvailable == FqName("kotlin.Function1") && + attrs.arguments.firstOrNull()?.typeOrNull?.classOrNull?.owner?.classId == ClassId.topLevel( + FqName(InputScope) + ) + ) + }.toSet().takeIf { it.isNotEmpty() } + ) + } +} diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/ir/IrSupport.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/ir/IrSupport.kt new file mode 100644 index 0000000000..99fa6346c2 --- /dev/null +++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/ir/IrSupport.kt @@ -0,0 +1,114 @@ +/* + * 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.ir + +import java.util.Collections +import java.util.IdentityHashMap +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.IrElement +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.IrBuilderWithScope +import org.jetbrains.kotlin.ir.builders.declarations.buildFun +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.declarations.IrDeclarationOrigin +import org.jetbrains.kotlin.ir.declarations.IrDeclarationParent +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrConstKind +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.IrReturn +import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin +import org.jetbrains.kotlin.ir.expressions.impl.IrFunctionExpressionImpl +import org.jetbrains.kotlin.ir.symbols.IrFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.IrSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.util.hasAnnotation +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 +import org.jetbrains.kotlin.name.SpecialNames + +private val Composable = FqName("androidx.compose.runtime.Composable") +internal val KotlinLet = FqName("kotlin.let") +internal val KotlinRepeat = FqName("kotlin.repeat") + +// Checks this element too and stops at the first match. +internal fun IrElement.anyDescendant(predicate: (IrElement) -> Boolean): Boolean { + var found = false + acceptVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + if (found) return + found = predicate(element) + if (!found) element.acceptChildrenVoid(this) + } + }) + return found +} + +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal fun hasComposableCall(expression: IrExpression?): Boolean = + expression?.anyDescendant { + it is IrCall && (it.symbol.owner.hasAnnotation(Composable) || + it.dispatchReceiver?.type?.hasAnnotation(Composable) == true) + } == true + +internal fun IrExpression?.isAbsent(): Boolean = this == null || (this is IrConst && kind == IrConstKind.Null) + +internal fun IrBuilderWithScope.irCallWithArgs(symbol: IrSimpleFunctionSymbol, vararg values: IrExpression?): IrCall = + irCall(symbol).apply { + values.forEachIndexed { index, value -> arguments[index] = value } + } + +// Collects value references and return targets in this subtree, including nested lambdas. +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal fun IrElement.contentScopeReferences(): Set { + val referenced: MutableSet = Collections.newSetFromMap(IdentityHashMap()) + acceptVoid(object : IrVisitorVoid() { + override fun visitElement(element: IrElement) { + when (element) { + is IrGetValue -> referenced.add(element.symbol) + is IrReturn -> referenced.add(element.returnTargetSymbol) + } + element.acceptChildrenVoid(this) + } + }) + return referenced +} + +// Unwrap only this lambda's return. Keep non-local returns for the caller to reject. +internal fun IrStatement.unwrapReturn(target: IrFunctionSymbol): IrStatement = + if (this is IrReturn && returnTargetSymbol == target) value else this + +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal fun DeclarationIrBuilder.irLambda( + context: IrPluginContext, + type: IrType, + build: IrSimpleFunction.() -> Unit, +): IrFunctionExpression { + val function = context.irFactory.buildFun { + name = SpecialNames.ANONYMOUS + origin = IrDeclarationOrigin.LOCAL_FUNCTION_FOR_LAMBDA + visibility = DescriptorVisibilities.LOCAL + returnType = context.irBuiltIns.unitType + } + function.parent = scope.scopeOwnerSymbol.owner as IrDeclarationParent + function.build() + return IrFunctionExpressionImpl(startOffset, endOffset, type, function, IrStatementOrigin.LAMBDA) +} + +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal fun IrSimpleFunctionSymbol.withOverrides(): Sequence = sequence { + yield(this@withOverrides) + for (symbol in owner.overriddenSymbols) yieldAll(symbol.withOverrides()) +} diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/ConstantEvaluator.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/ConstantEvaluator.kt new file mode 100644 index 0000000000..41d6a51152 --- /dev/null +++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/ConstantEvaluator.kt @@ -0,0 +1,144 @@ +/* + * 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.ir.IrStatement +import org.jetbrains.kotlin.ir.declarations.IrVariable +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrConstKind +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.expressions.IrGetValue +import org.jetbrains.kotlin.ir.expressions.IrStringConcatenation +import org.jetbrains.kotlin.ir.expressions.IrWhen +import org.jetbrains.kotlin.ir.expressions.impl.IrCallImpl +import org.jetbrains.kotlin.ir.expressions.impl.IrStringConcatenationImpl +import org.jetbrains.kotlin.ir.interpreter.IrInterpreter +import org.jetbrains.kotlin.ir.interpreter.IrInterpreterConfiguration +import org.jetbrains.kotlin.ir.interpreter.IrInterpreterEnvironment +import org.jetbrains.kotlin.ir.interpreter.checker.EvaluationMode +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI + +/** + * Computes constant values without running application code. + * Follows local `val` references and supports built-in operations and constant branches. + * + * ```kotlin + * const val APP_NAME = "Compose" + * + * @Composable + * fun Page() { + * val label = "Hello, " + APP_NAME + * Div { Text(label) } // The text can be computed at compile time. + * } + * ``` + * + * Kotlin resolves `const val` references before this pass. + * Unsupported expressions and values that exceed the limits stay at runtime. + */ +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class ConstantEvaluator(context: IrPluginContext) { + private val interpreter = IrInterpreter( + IrInterpreterEnvironment( + context.irBuiltIns, + IrInterpreterConfiguration( + platform = context.platform, + maxStack = MaxInterpreterStackDepth, + maxCommands = MaxInterpreterCommands, + ) + ) + ) + private val aliases = mutableMapOf() + private var work = MaxFoldSteps + private var limited = false + + // Return null when the value cannot be safely computed at compile time. + fun string(expression: IrExpression?): String? = evaluate(expression)?.value as? String + + fun stringWithoutNul(expression: IrExpression?): String? = string(expression)?.withoutNul() + + fun integer(expression: IrExpression?): Int? = evaluate(expression)?.value as? Int + + fun isConstantAlias(statement: IrStatement): Boolean = + statement is IrVariable && !statement.isVar && evaluate(statement.initializer) != null + + private fun evaluate(expression: IrExpression?): IrConst? { + work = MaxFoldSteps + limited = false + return foldPortableConstant(expression, 0) + } + + private fun foldPortableConstant(value: IrExpression?, depth: Int): IrConst? = + foldExpression(value, depth)?.takeIf(::isPortable) + + private fun isPortable(constant: IrConst): Boolean = + // Compile-time floating-point formatting can differ from the runtime's formatNumber. + constant.kind != IrConstKind.Float && constant.kind != IrConstKind.Double && + ((constant.value as? String)?.length ?: 0) <= MaxEvaluatedStringLength + + private fun foldExpression(value: IrExpression?, depth: Int): IrConst? { + if (value == null) return null + if (depth > MaxFoldDepth || --work < 0) { + limited = true + return null + } + return when (value) { + is IrConst -> value + is IrGetValue -> { + val variable = value.symbol.owner as? IrVariable ?: return null + if (variable.isVar) return null + + if (variable in aliases) return aliases[variable] + val constant = foldPortableConstant(variable.initializer, depth + 1) + if (!limited) aliases[variable] = constant + constant + } + + is IrStringConcatenation -> { + val arguments = value.arguments.map { foldPortableConstant(it, depth + 1) ?: return null } + // Bound concatenation before asking the interpreter to allocate its result. + if (arguments.sumOf { (it.value?.toString() ?: "null").length.toLong() } > MaxEvaluatedStringLength) { + return null + } + val copy = IrStringConcatenationImpl(value.startOffset, value.endOffset, value.type) + copy.arguments.addAll(arguments) + interpreter.interpret(copy) as? IrConst + } + + is IrCall -> { + // OnlyBuiltins also accepts property accessors. Do not evaluate those here. + if (value.symbol.owner.correspondingPropertySymbol != null || + !EvaluationMode.OnlyBuiltins.canEvaluateFunction(value.symbol.owner) + ) return null + + val copy = IrCallImpl( + value.startOffset, value.endOffset, value.type, value.symbol, + typeArgumentsCount = value.typeArguments.size + ) + for (index in value.typeArguments.indices) copy.typeArguments[index] = value.typeArguments[index] + for (index in value.arguments.indices) { + copy.arguments[index] = foldPortableConstant(value.arguments[index], depth + 1) ?: return null + } + interpreter.interpret(copy) as? IrConst + } + + is IrWhen -> { + // Evaluate only the selected branch, preserving short-circuit behavior. + for (branch in value.branches) { + val condition = foldPortableConstant(branch.condition, depth + 1)?.value as? Boolean ?: return null + if (condition) return foldPortableConstant(branch.result, depth + 1) + } + null + } + + else -> null + } + } +} + +// Leave strings containing NUL to runtime validation. Raw text uses separate normalization. +internal fun String.withoutNul(): String? = takeUnless { '\u0000' in it } diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/ElementLowering.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/ElementLowering.kt new file mode 100644 index 0000000000..2d014fbddd --- /dev/null +++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/ElementLowering.kt @@ -0,0 +1,331 @@ +/* + * 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.irCallWithArgs +import org.jetbrains.compose.web.dom.escapeHtml +import org.jetbrains.compose.web.dom.isHtmlLeadingNewlineElement +import org.jetbrains.compose.web.dom.isHtmlVoidElement +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.irBlock +import org.jetbrains.kotlin.ir.builders.irBoolean +import org.jetbrains.kotlin.ir.builders.irGet +import org.jetbrains.kotlin.ir.builders.irIfThen +import org.jetbrains.kotlin.ir.builders.irIfThenElse +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.IrDeclarationParent +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.symbols.IrSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.patchDeclarationParents + +// Replaces elements with HTML strings, keeping runtime calls where needed. +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class ElementLowering( + private val context: IrPluginContext, + private val symbols: HtmlSymbols, + private val analysis: HtmlAnalysis, + private val attributeWriter: HtmlAttributeWriter, + private val transform: (IrStatement) -> IrStatement, +) { + private val functionChunks = mutableMapOf() + private var fileChunks = 0 + + private fun remaining(owner: IrSymbol): Int = + minOf(MaxGeneratedChunksPerFunction - (functionChunks[owner] ?: 0), MaxGeneratedChunksPerFile - fileChunks) + + private fun consume(owner: IrSymbol, chunks: Int) { + check(chunks <= remaining(owner)) + functionChunks.merge(owner, chunks, Int::plus) + fileChunks += chunks + } + + private sealed interface Operation + + // An HTML chunk and the parent tag and namespace to use for the following runtime calls. + private class Write(val html: String, val parent: String, val namespace: String) : Operation + private class Statement(val ir: IrStatement) : Operation + + private class RegionPlan( + val root: String, + val namespace: String, + val attributes: HtmlAttributes, + private val budget: Int + ) { + val operations = mutableListOf() + val aliases = mutableListOf() + private val pending = StringBuilder() + private var parent = root + private var parentNamespace = namespace + private var flushedLength = 0L + var chunks = 0 + private set + val endsWithTag: Boolean get() = pending.lastOrNull() == '>' + private var closingLength = 0L + private var closingChunks = 0 + + // Leave room for every closing tag before adding an opening tag. + // Runtime calls can split the HTML into separate chunks. + private fun fits(addition: Int, closing: String = ""): Boolean = + flushedLength + pending.length + addition + closingLength + closing.length <= MaxEncodedFragmentLength && + chunks + chunkBound(pending.length + addition) + closingChunks + chunkBound(closing.length) <= budget + + // Add the opening, body and closing together, then restore the parent. + inline fun element( + tag: String, + namespace: String, + opening: String, + prefix: String = "", + body: () -> Unit + ): Boolean { + val closing = if (isHtmlVoidElement(tag, namespace)) "" else "" + if (!fits(prefix.length + opening.length, closing)) return false + closingLength += closing.length + closingChunks += chunkBound(closing.length) + val previous = parent + val previousNamespace = parentNamespace + parent = tag + parentNamespace = namespace + pending.append(prefix) + pending.append(opening) + body() + parent = previous + parentNamespace = previousNamespace + closingLength -= closing.length + closingChunks -= chunkBound(closing.length) + pending.append(closing) + return true + } + + fun tryAppend(html: String): Boolean = fits(html.length).also { if (it) pending.append(html) } + + fun tryAppend(html: StaticHtml, prefix: String = ""): Boolean { + if (!fits(prefix.length + html.length)) return false + pending.append(prefix) + html.appendTo(pending) + return true + } + + fun flush() { + if (pending.isEmpty()) return + val literals = htmlChunks(pending.toString()) + chunks += literals.size + flushedLength += pending.length + for (literal in literals) operations.add(Write(literal, parent, parentNamespace)) + pending.setLength(0) + } + + fun statement(statement: IrStatement) { + flush() + operations.add(Statement(statement)) + } + } + + /** + * Replaces an element with static HTML or HTML mixed with runtime calls. + * + * ```kotlin + * Div { Text("Hello") } // Becomes a single HTML write. + * Div { Text(name) } // Keeps the dynamic text at runtime. + * ``` + * + * Returns `null` if the element cannot be lowered within the size limits. + */ + fun lower( + element: HtmlElement, + builder: DeclarationIrBuilder, + owner: IrSymbol, + ): IrExpression? { + val node = analysis.analyze(element) + node.html?.let { lowerStatic(it, builder, owner) }?.let { return it } + return lowerRegion(element, node, builder, owner) + } + + /** + * Groups static HTML around calls that still run at runtime. + * + * ```kotlin + * Div { + * Span { Text("Hello") } + * Text(name) + * } => RegionPlan: + * Write("
Hello") + * Statement(Text(name)) + * Write("
") + * ``` + */ + private fun lowerRegion( + element: HtmlElement, + node: HtmlAnalysis.Node, + builder: DeclarationIrBuilder, + owner: IrSymbol, + ): IrExpression? { + val children = node.movableChildren ?: return null + val attributes = element.attributes + if (attributes is MixedAttributes && !attributeWriter.canEmit(attributes)) return null + + val plan = RegionPlan(element.tag, element.namespace, attributes, remaining(owner)) + val planned = plan.element(element.tag, element.namespace, element.openingTag.orEmpty()) { + if (node.canMergeContent) add(plan, children, depth = 0) + else { + // Keep special-element children at runtime for parser checks and text boundaries. + for (child in children) plan.statement(child.statement) + } + // Keep opening and closing separate when no runtime statement flushed the opening: + // otherwise openLoweredRegion and closeElement would write the same chunk. + if (attributes is LiteralAttributes && plan.operations.isEmpty()) plan.flush() + } + + if (!planned) return null + plan.flush() + // Consume before emitting so nested lowerings see the reduced budget. + consume(owner, plan.chunks) + + return emit(plan, builder).also { + it.patchDeclarationParents(builder.scope.scopeOwnerSymbol.owner as IrDeclarationParent) + } + } + + private fun add(plan: RegionPlan, children: List, depth: Int) { + var index = 0 + while (index < children.size) { + val child = children[index++] + val absorbed = when (child) { + is HtmlChild.Alias -> { + plan.aliases.add(child.statement) + true + } + + is HtmlChild.Element -> absorb(plan, child.node, depth) + is HtmlChild.Text -> { + // Pure aliases are hoisted even when text or the following element stays at runtime. + while (children.getOrNull(index) is HtmlChild.Alias) { + plan.aliases.add(children[index++].statement) + } + absorbText(plan, child, children.getOrNull(index), depth) + .also { if (it && index < children.size) index++ } + } + + is HtmlChild.Runtime -> false + } + if (!absorbed) plan.statement(child.statement) + } + } + + private fun absorbText(plan: RegionPlan, text: HtmlChild.Text, next: HtmlChild?, depth: Int): Boolean { + if (!plan.endsWithTag || (next != null && next !is HtmlChild.Element)) return false + val value = text.valueWithoutNul?.takeIf { it.isNotEmpty() } ?: return false + // Admit text together with its following element or the enclosing closing tag. + val html = escapeHtml(value, attribute = false) + return if (next is HtmlChild.Element) absorb(plan, next.node, depth, html) else plan.tryAppend(html) + } + + private fun absorb(plan: RegionPlan, node: HtmlAnalysis.Node, depth: Int, prefix: String = ""): Boolean { + val html = node.html + if (html != null && plan.tryAppend(html, prefix)) return true + if (!node.canMergeContent) return false + if (depth >= MaxHtmlNestingDepth) return false + val element = node.element + val opening = element.openingTag ?: return false + val children = node.movableChildren ?: return false + return plan.element(element.tag, element.namespace, opening, prefix) { add(plan, children, depth + 1) } + } + + // Writes the planned HTML and runtime statements, updating the writer's parent as it goes. + private fun emit(plan: RegionPlan, builder: DeclarationIrBuilder) = + builder.irWriterScope(symbols, context.irBuiltIns.unitType) { writer, validate -> + for (alias in plan.aliases) +transform(alias) + val root = plan.root + val operations = ArrayDeque(plan.operations) + val opening = when (val attributes = plan.attributes) { + is LiteralAttributes -> { + val first = operations.removeFirst() as Write + irCallWithArgs( + symbols.openLoweredRegion, + irGet(writer), irString(root), irString(first.html), irString(first.parent), + irGet(validate), irString(first.namespace), + irBoolean(isHtmlLeadingNewlineElement(first.parent, first.namespace)) + ) + } + + is MixedAttributes -> irIfThenElse( + symbols.finishMixedElement.owner.returnType, + irCallWithArgs(symbols.beginMixedElement, irGet(writer), irString(root), irGet(validate)), + transform(attributeWriter.emit(attributes, builder, writer, validate)) as IrExpression, + irNull() + ) + } + val closing = if (isHtmlVoidElement(root, plan.namespace)) null else operations.removeLast() as Write + val parent = irTemporary(opening, "htmlParent") + for (operation in operations) { + +when (operation) { + is Statement -> transform(operation.ir) + is Write -> irCallWithArgs( + symbols.advanceLoweredRegion, + irGet(writer), + irString(operation.html), + irString(operation.parent), + irString(operation.namespace), + irBoolean(isHtmlLeadingNewlineElement(operation.parent, operation.namespace)) + ) + } + } + if (closing != null) +irCallWithArgs( + symbols.closeElement, + irGet(writer), irString(closing.html), irGet(parent) + ) + } + + // Writes a complete HTML fragment, splitting large strings into chunks. + // Returns null if the chunks would exceed the function or file limit. + private fun lowerStatic(html: StaticHtml, builder: DeclarationIrBuilder, owner: IrSymbol): IrExpression? { + if (chunkBound(html.length) > remaining(owner)) return null + val chunks = htmlChunks(html.html) + consume(owner, chunks.size) + if (chunks.size == 1) return builder.irCallWithArgs( + symbols.writeStaticHtml, builder.irString(html.tagName), builder.irString(chunks.single()) + ) + + return builder.irWriterScope(symbols, context.irBuiltIns.unitType) { writer, validate -> + val begin = irCallWithArgs( + symbols.beginStaticHtmlFragment, + irGet(writer), irString(html.tagName), irGet(validate) + ) + +irIfThen(context.irBuiltIns.unitType, begin, irBlock(resultType = context.irBuiltIns.unitType) { + for (chunk in chunks) +irCallWithArgs(symbols.appendStaticHtmlChunk, irGet(writer), irString(chunk)) + }) + } + } +} + +// Upper bound on chunk count. Splitting a surrogate pair can move one code unit to the next chunk. +private fun chunkBound(length: Int): Int = when { + length == 0 -> 0 + length <= MaxHtmlChunkLength -> 1 + else -> (length + MaxHtmlChunkLength - 2) / (MaxHtmlChunkLength - 1) +} + +// Splits HTML into literals small enough for the JVM, keeping surrogate pairs together. +private fun htmlChunks(html: String): List { + if (html.length <= MaxHtmlChunkLength) return listOf(html) + val chunks = mutableListOf() + var start = 0 + while (start < html.length) { + var end = minOf(start + MaxHtmlChunkLength, html.length) + if (end < html.length && html[end - 1].isHighSurrogate() && html[end].isLowSurrogate()) end-- + chunks.add(html.substring(start, end)) + start = end + } + return chunks +} diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/FileLowering.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/FileLowering.kt new file mode 100644 index 0000000000..8a02d72b9a --- /dev/null +++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/FileLowering.kt @@ -0,0 +1,91 @@ +/* + * 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.kotlin.backend.common.IrElementTransformerVoidWithContext +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.cli.reportInfo +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.declarations.IrFile +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI + +// Lowers one file and keeps its analysis, generated fields and size limits together. +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class FileLowering( + private val context: IrPluginContext, + private val configuration: CompilerConfiguration, + private val symbols: HtmlSymbols, + private val file: IrFile, +) : IrElementTransformerVoidWithContext() { + private val constants = ConstantEvaluator(context) + private val attributeAnalyzer = HtmlAttributeAnalyzer(context, symbols, constants) + private val wrappers = WrapperCallNormalizer(context, symbols, constants, attributeAnalyzer) + private val elements = wrappers.elements + private val fields = HtmlFields(context, file) + private val analysis = HtmlAnalysis(symbols, constants, elements) + private val attributeWriter = HtmlAttributeWriter(context, symbols, constants, fields) + private val elementLowering = ElementLowering(context, symbols, analysis, attributeWriter, ::transform) + private val prepared = PreparedElementLowering(context, symbols, fields) + + private var receiverCallsOnly = true + + // Normalize wrappers, lower elements, then add the fields needed by the generated code. + fun lower() { + file.transform(wrappers, null) + // Lower calls with a content receiver first. Then enclosing content can move if it no longer + // uses that receiver. Calls left at runtime keep their original scope. + file.transform(this, null) + receiverCallsOnly = false + file.transform(this, null) + + file.declarations.addAll(0, prepared.fields) + file.declarations.addAll(0, fields.stringFields) + + if (prepared.fields.isNotEmpty()) { + configuration.reportInfo( + "[compose-html-lowering] ${file.fileEntry.name}: " + + "prepared ${prepared.fields.size} HTML attribute value(s)" + ) + } + } + + // Try static HTML or a mix of HTML and runtime calls. Otherwise lower the children + // and try caching the attributes for the original element call. + override fun visitCall(expression: IrCall): IrExpression { + if (receiverCallsOnly) { + val visited = super.visitCall(expression) as IrCall + if (expression.symbol.owner.parameters.none { it.kind == IrParameterKind.ExtensionReceiver }) return visited + val element = elements[visited] ?: return visited + return elementLowering.lower(element, builder(visited), owner()) ?: visited + } + val element = elements[expression] + val lowered = element?.let { elementLowering.lower(it, builder(expression), owner()) } + if (lowered != null) return lowered + + val visited = super.visitCall(expression) + return element?.let { prepared.lower(it, builder(expression)) } ?: visited + } + + private fun transform(statement: IrStatement): IrStatement = statement.transform(this, null) as IrStatement + + // Count moved content against the enclosing function's chunk limit. + private fun owner() = requireNotNull(allScopes.firstOrNull { it.irElement is IrFunction } ?: currentScope) + .scope.scopeOwnerSymbol + + private fun builder(expression: IrCall) = DeclarationIrBuilder( + context, + requireNotNull(currentScope).scope.scopeOwnerSymbol, + expression.startOffset, + expression.endOffset, + ) +} diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlAnalysis.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlAnalysis.kt new file mode 100644 index 0000000000..1c657ca755 --- /dev/null +++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlAnalysis.kt @@ -0,0 +1,286 @@ +/* + * 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.KotlinRepeat +import org.jetbrains.compose.html.compiler.ir.contentScopeReferences +import org.jetbrains.compose.html.compiler.ir.isAbsent +import org.jetbrains.compose.web.dom.HtmlNamespace +import org.jetbrains.compose.web.dom.appendHydrationTextBoundaryMarker +import org.jetbrains.compose.web.dom.escapeHtml +import org.jetbrains.compose.web.dom.isHtmlLeadingNewlineElement +import org.jetbrains.compose.web.dom.isHtmlParserStableTableElement +import org.jetbrains.compose.web.dom.isHtmlParserStableTableText +import org.jetbrains.compose.web.dom.isHtmlRawTextElement +import org.jetbrains.compose.web.dom.isHtmlRcdataElement +import org.jetbrains.compose.web.dom.isHtmlVoidElement +import org.jetbrains.compose.web.dom.isValidRawTextContent +import org.jetbrains.compose.web.dom.normalizeHtmlInputCharacters +import org.jetbrains.compose.web.dom.supportsPreparedHtmlAttributes +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.expressions.IrBlockBody +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.fqNameWhenAvailable + +internal sealed interface HtmlEncoding + +// Stores encoded HTML as strings and child fragments, avoiding copies of nested HTML. +internal class StaticHtml( + val tagName: String, + private val parts: List, + val length: Int, + val height: Int, +) : HtmlEncoding { + constructor(tagName: String, html: String) : this(tagName, listOf(html), html.length, 0) + + val html: String by lazy(LazyThreadSafetyMode.NONE) { + parts.singleOrNull() as? String ?: buildString(length) { appendTo(this) } + } + + fun appendTo(output: StringBuilder) { + for (part in parts) { + if (part is String) output.append(part) else (part as StaticHtml).appendTo(output) + } + } +} + +// Checks which elements can become static HTML and which content can move out of its lambda. +@OptIn(UnsafeDuringIrConstructionAPI::class) +internal class HtmlAnalysis( + private val symbols: HtmlSymbols, + private val constants: ConstantEvaluator, + private val elements: Map, +) { + private object Unsupported : HtmlEncoding + + // Hitting the depth limit here must still allow a later attempt starting at this element. + private object TooDeep : HtmlEncoding + + private val cache = IdentityHashMap() + + // Children retain original statements until the chosen plan is emitted. + inner class Node(val element: HtmlElement) { + val noContent = element.content.isAbsent() + private val statements = if (noContent) emptyList() else + ((element.content as? IrFunctionExpression)?.function?.body as? IrBlockBody)?.statements + val children: List? by lazy(LazyThreadSafetyMode.NONE) { statements?.map(::classify) } + val movableChildren: List? by lazy(LazyThreadSafetyMode.NONE) { + if (canMoveContent()) children else null + } + val canMergeContent: Boolean get() = hasMergeableContent(element.tag, element.namespace) + val html: StaticHtml? get() = encode(0) as? StaticHtml + private var encoded: HtmlEncoding? = null + + fun encode(depth: Int): HtmlEncoding { + encoded?.let { return it } + if (depth > MaxEncodingRecursion) return TooDeep + var result = encodeUncached(this, depth) + // At this depth, reaching the recursion limit proves the subtree exceeds MaxHtmlNestingDepth. + // Cache the rejection, retrying at a smaller depth cannot make the subtree fit. + if (result === TooDeep && MaxEncodingRecursion - depth > MaxHtmlNestingDepth) result = Unsupported + if (result !== TooDeep) encoded = result + return result + } + + // Moving the body must preserve parser behavior and must not need the original lambda's + // receiver or return target. + private fun canMoveContent(): Boolean { + val tag = element.tag + // Empty elements have no child parser rules to preserve. The mixed writer checks their parent. + if (noContent && element.attributes is MixedAttributes && tag != "noscript") return true + val void = isHtmlVoidElement(tag, element.namespace) + // Literal regions enter their root, a void root would never leave it. + if ((element.namespace == HtmlNamespace && !supportsPreparedHtmlAttributes(tag)) || + (void && element.attributes is LiteralAttributes) + ) return false + if (noContent) return true + val body = statements ?: return false + val function = (element.content as? IrFunctionExpression)?.function ?: return false + if (void && body.isNotEmpty()) return false + val references = requireNotNull(element.content).contentScopeReferences() + if (function.symbol in references || function.parameters.any { it.symbol in references }) return false + return true + } + } + + // Analyze each original element once and share the result between lowering attempts. + fun analyze(element: HtmlElement): Node = cache.getOrPut(element.call) { Node(element) } + + private fun classify(statement: IrStatement): HtmlChild { + if (constants.isConstantAlias(statement)) return HtmlChild.Alias(statement) + val call = statement as? IrCall ?: return HtmlChild.Runtime(statement) + if (call.symbol == symbols.text) return HtmlChild.Text(call, constants) + val element = elements[call] ?: return HtmlChild.Runtime(call) + return HtmlChild.Element(analyze(element)) + } + + private fun encodeUncached(node: Node, depth: Int): HtmlEncoding { + val element = node.element + val tag = element.tag + if (element.namespace == HtmlNamespace && tag == "noscript") return Unsupported + val opening = element.openingTag ?: return Unsupported + + val fragment = if (isHtmlVoidElement(tag, element.namespace)) { + // Void content still executes at runtime, even though its HTML is discarded. + if (!node.noContent) return Unsupported + StaticHtml(tag, opening) + } else { + val children = expandRepeats(node.children ?: return Unsupported) ?: return Unsupported + if (isHtmlRawTextElement(tag, element.namespace)) { + val html = encodeRawText(tag, opening, children) ?: return Unsupported + StaticHtml(tag, html) + } else { + val result = encodeChildren( + tag, + element.namespace, + opening, + children, + isHtmlRcdataElement(tag, element.namespace), + depth + ) + result as? StaticHtml ?: return result + } + } + if (fragment.length > MaxEncodedFragmentLength) return Unsupported + return fragment + } + + private fun encodeRawText(tag: String, opening: String, children: List): String? { + val text = StringBuilder() + for (child in children) { + if (child !is HtmlChild.Text) return null + text.append(child.value ?: return null) + if (text.length > MaxEncodedFragmentLength) return null + } + // Join before normalizing and validating so tags and CRLF split across Text calls are checked together. + // Raw text replaces NUL with U+FFFD, matching runtime serialization and HTML parsing. + val value = text.toString().normalizeHtmlInputCharacters() + if (!isValidRawTextContent(tag, value)) return null + return "$opening$value" + } + + // Escapes text and references encoded child elements. + private fun encodeChildren( + tag: String, + namespace: String, + opening: String, + children: List, + rcdata: Boolean, + depth: Int, + ): HtmlEncoding { + val parts = mutableListOf() + val pending = StringBuilder(opening) + var length = 0 + var height = 0 + var firstContent = true + var previousWasText = false + for (child in children) { + if (child is HtmlChild.Text) { + val value = child.valueWithoutNul ?: return Unsupported + if (namespace == HtmlNamespace && !isHtmlParserStableTableText(tag, value)) return Unsupported + if (value.isNotEmpty()) { + if (previousWasText && !rcdata) pending.appendHydrationTextBoundaryMarker() + if (firstContent && isHtmlLeadingNewlineElement(tag, namespace) && value.first() == '\n') { + pending.append("\n") + } + pending.append(escapeHtml(value, attribute = false)) + firstContent = false + previousWasText = true + } + } else { + // RCDATA cannot contain parsed element children. + if (rcdata) return Unsupported + val element = child as? HtmlChild.Element ?: return Unsupported + val result = element.node.encode(depth + 1) + val fragment = result as? StaticHtml ?: return result + if (fragment.height + 1 > MaxHtmlNestingDepth) return Unsupported + if (namespace == HtmlNamespace && !isHtmlParserStableTableElement( + tag, + fragment.tagName + ) + ) return Unsupported + if (pending.isNotEmpty()) { + parts.add(pending.toString()) + length += pending.length + pending.setLength(0) + } + parts.add(fragment) + length += fragment.length + height = maxOf(height, fragment.height + 1) + firstContent = false + previousWasText = false + } + if (length.toLong() + pending.length > MaxEncodedFragmentLength) return Unsupported + } + pending.append("") + parts.add(pending.toString()) + length += pending.length + return StaticHtml(tag, parts, length, height) + } + + // Expand constant repeat calls only when the whole body can become static HTML. + private fun expandRepeats(statements: List, depth: Int = 0): List? { + if (depth > MaxHtmlNestingDepth) return null + val calls = mutableListOf() + for (statement in statements) { + // The entire content lambda is replaced; its pure local aliases have no remaining uses. + if (statement is HtmlChild.Alias) continue + val call = statement.statement as? IrCall ?: return null + if (call.symbol.owner.fqNameWhenAvailable == KotlinRepeat) { + val count = constants.integer(call.arguments.getOrNull(0)) ?: return null + if (count <= 0) continue + if (count > MaxStaticRepeatCount) return null + val content = call.arguments.getOrNull(1) as? IrFunctionExpression ?: return null + val body = content.function.body as? IrBlockBody ?: return null + val children = expandRepeats(body.statements.map(::classify), depth + 1) ?: return null + if (children.isEmpty()) continue + // Bound expansion independently of encoded output and literal chunk sizes. + if (calls.size.toLong() + children.size.toLong() * count > MaxExpandedHtmlCalls) return null + repeat(count) { calls.addAll(children) } + } else { + if (statement is HtmlChild.Runtime) return null + calls.add(statement) + } + if (calls.size > MaxExpandedHtmlCalls) return null + } + return calls + } +} + +internal sealed class HtmlChild(val statement: IrStatement) { + class Text(statement: IrCall, private val constants: ConstantEvaluator) : HtmlChild(statement) { + private object Unevaluated + + // Cache failed folds too, without allocating a Lazy for each Text call. + private var constant: Any? = Unevaluated + val valueWithoutNul: String? get() = value?.withoutNul() + val value: String? + get() { + if (constant === Unevaluated) constant = + constants.string((statement as IrCall).arguments.singleOrNull()) + return constant as? String + } + } + + class Element(val node: HtmlAnalysis.Node) : HtmlChild(node.element.call) + class Alias(statement: IrStatement) : HtmlChild(statement) + class Runtime(statement: IrStatement) : HtmlChild(statement) +} + +// These tags allow child writes to merge without losing parser checks or text state. +private fun hasMergeableContent(tag: String, namespace: String): Boolean = + (namespace != HtmlNamespace || (tag != "noscript" && tag !in TableTags)) && + !isHtmlRawTextElement(tag, namespace) && !isHtmlRcdataElement(tag, namespace) && + !isHtmlLeadingNewlineElement(tag, namespace) + +private val TableTags = setOf("table", "caption", "colgroup", "col", "thead", "tbody", "tfoot", "tr", "td", "th") diff --git a/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlAttributeWriter.kt b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlAttributeWriter.kt new file mode 100644 index 0000000000..a60db82d56 --- /dev/null +++ b/html/eap/compiler-plugin/src/main/kotlin/org/jetbrains/compose/html/compiler/lowering/HtmlAttributeWriter.kt @@ -0,0 +1,460 @@ +/* + * 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.irCallWithArgs +import org.jetbrains.compose.html.compiler.ir.unwrapReturn +import org.jetbrains.compose.web.dom.escapeHtml +import org.jetbrains.compose.web.dom.isHtmlLeadingNewlineElement +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.ir.IrStatement +import org.jetbrains.kotlin.ir.builders.IrBlockBuilder +import org.jetbrains.kotlin.ir.builders.irBlock +import org.jetbrains.kotlin.ir.builders.irBoolean +import org.jetbrains.kotlin.ir.builders.irEquals +import org.jetbrains.kotlin.ir.builders.irEqualsNull +import org.jetbrains.kotlin.ir.builders.irFalse +import org.jetbrains.kotlin.ir.builders.irGet +import org.jetbrains.kotlin.ir.builders.irIfThen +import org.jetbrains.kotlin.ir.builders.irIfThenElse +import org.jetbrains.kotlin.ir.builders.irImplicitCast +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.irSet +import org.jetbrains.kotlin.ir.builders.irString +import org.jetbrains.kotlin.ir.builders.irTemporary +import org.jetbrains.kotlin.ir.builders.irTrue +import org.jetbrains.kotlin.ir.builders.irWhile +import org.jetbrains.kotlin.ir.declarations.IrVariable +import org.jetbrains.kotlin.ir.expressions.IrBlock +import org.jetbrains.kotlin.ir.expressions.IrBlockBody +import org.jetbrains.kotlin.ir.expressions.IrCall +import org.jetbrains.kotlin.ir.expressions.IrConst +import org.jetbrains.kotlin.ir.expressions.IrContainerExpression +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.expressions.IrGetValue +import org.jetbrains.kotlin.ir.expressions.IrReturn +import org.jetbrains.kotlin.ir.expressions.IrTypeOperatorCall +import org.jetbrains.kotlin.ir.expressions.IrWhen +import org.jetbrains.kotlin.ir.symbols.IrValueSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.makeNullable +import org.jetbrains.kotlin.ir.util.deepCopyWithSymbols +import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid + +private val AttributeSlotKinds = setOf( + AttributeOperationKind.Attribute, AttributeOperationKind.FormValue, AttributeOperationKind.FormChecked +) + +private val PropertySlotKinds = setOf( + AttributeOperationKind.FormValue, AttributeOperationKind.FormChecked, + AttributeOperationKind.TextContent, AttributeOperationKind.TextDefault +) + +/** + * Writes analyzed attributes directly, without creating an attribute builder. + * + * ```kotlin + * Div(attrs = { + * attr("title", loadTitle()) + * }) => + * val title = loadTitle() + * writer.writeMixedLiteral(" 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() + val jumps = mutableListOf>() + val contextReads = mutableListOf() + var position = 0 + sampleBytecode("LoopRenderingKt").accept(object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array? + ): MethodVisitor? { + if (name != method) return null + return object : MethodVisitor(Opcodes.ASM9) { + override fun visitMethodInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean + ) { + calls.add(name) + if (name in setOf( + "currentHtmlStringWriter", + "getLocalHtmlValidationMode" + ) + ) contextReads.add(position) + position++ + } + + override fun visitLabel(label: Label) { + labels[label] = position++ + } + + override fun visitJumpInsn(opcode: Int, label: Label) { + jumps.add(position++ to label) + } + } + } + }, 0) + assertEquals(1, calls.count { it == "currentHtmlStringWriter" }, "$method: $calls") + assertEquals(1, calls.count { it == "getLocalHtmlValidationMode" }, "$method: $calls") + assertFalse( + calls.any { it in setOf("Div", "Span", "Text", "PreparedContainer", "PreparedTextElement") }, + "$method: $calls" + ) + for ((from, label) in jumps) { + val target = labels.getValue(label) + if (target < from) assertFalse( + contextReads.any { it in target..from }, + "$method reads context inside a loop" + ) + } + } + } + + @Test + fun emptyLoopsEscapingAndControlFlowPreserveOutputAndEffects() { + forEachRenderMode { strict, hydratable, stream -> + val effects = mutableListOf() + val content: @Composable () -> Unit = { + LoopRows(listOf("<&", "", "é😀")) + TopLevelLoop(emptyList()) + TopLevelForEach(listOf("<&", "")) + NestedLoops(listOf("", "a", "stop", "unused"), effects::add) + } + val expected = "
<&<&
" + + "
é😀é😀
" + + "0:<&1:" + + "
aaastopaunused" + + "aaastopaunuseddone:3
" + val actual = render(strict, hydratable, stream, content) + assertEquals(expected, actual) + assertEquals(listOf("", "a", "stop"), effects) + } + } + + @Test + fun storedCallbacksResolveTheWriterOfTheirInvocation() { + val callbacks = mutableListOf<@Composable () -> Unit>() + assertEquals("
<&<&
", composeHtmlToString { + LoopCallbacks(listOf("<&"), callbacks::add) + }) + assertEquals("stored:<&", composeHtmlToString(validateStrictly = true) { + callbacks.single().invoke() + }) + assertFailsWith { + composeHtmlToString(validateStrictly = false) { LoopValidationScope(listOf("invalid child")) } + } + } + + @Test + fun onlyBoundedStaticRepeatsFold() { + val html = "
" + "<&".repeat(3) + "
" + assertEquals(html, composeHtmlToString { ConstantRepeat() }) + assertTrue(html in staticHtmlConstants("LoopRenderingKt", "ConstantRepeat")) + var effects = 0 + assertEquals("
", composeHtmlToString { EmptyConstantRepeats { effects++ } }) + assertEquals(0, effects) + assertEquals("
012
", composeHtmlToString { IndexedRepeat() }) + assertEquals("
", composeHtmlToString { EffectfulRepeat { effects++ } }) + assertEquals(2, effects) + assertEquals("
authored:3
", composeHtmlToString { ShadowedRepeat { effects++ } }) + assertEquals(3, effects) + assertEquals( + "
" + "<&".repeat(10_000) + "
", + composeHtmlToString { LargeRepeat() }) + assertTrue(staticHtmlConstants("LoopRenderingKt", "LargeRepeat").none { it.length > 8_000 }) + } + + @Test + fun adjacentLoopTextRetainsHydrationBoundaries() { + assertEquals("
a<&
", composeHtmlToString(hydratable = false) { + LoopTextBoundaries(listOf("a", "", "<&")) + }) + assertEquals("
a<&
", composeHtmlToString(hydratable = true) { + LoopTextBoundaries(listOf("a", "", "<&")) + }) + assertEquals("a<&", composeHtmlToString(hydratable = true) { + TopLevelLoopText(listOf("a", "", "<&")) + }) + } + + @Test + fun regionsPreserveRenderingEffectsAndWriterState() { + comparison.assertEquivalent() + } + + @Test + fun aliasesAndBudgetFallbacksEmitExpectedWriterCalls() { + val calls = comparison.writerCalls + assertEquals(1, calls.getValue("TextAcrossAliases")["writeText"]) + assertEquals(2, calls.getValue("TextAcrossAliasesToText")["writeText"]) + assertEquals(1, calls.getValue("TextBeforeTrailingAliases")["writeText"]) + assertEquals(1, calls.getValue("TextBeforeAliasesAndBudgetFallback")["writeText"]) + assertEquals(mapOf("openLoweredRegion" to 1, "closeElement" to 1), calls.getValue("DeepConstant")) + assertEquals( + mapOf("openLoweredRegion" to 1, "writeText" to 1, "closeElement" to 1), + calls.getValue("WideSiblings") + ) + assertEquals(5, calls.getValue("LargeBatches")["advanceLoweredRegion"]) + } + + companion object { + private val cases = linkedMapOf( + "TextAcrossAliases" to """ + Div { + Text("lead") + val alias = "<&" + val second = alias + "!" + Span { + Probe() + Text(second) + } + Probe() + } + """.trimIndent(), + "TextAcrossAliasesToText" to "Div { Text(\"a\"); val alias = \"b\"; Text(alias); Probe() }", + "TextBeforeTrailingAliases" to "Div { Probe(); Span {}; Text(\"tail\"); val unused = \"alias\" }", + "TextAcrossEffect" to "Div { Text(\"lead\"); val alias = Mark(\"value\"); Span { Text(alias); Probe() } }", + "TextBeforeAliasesAndBudgetFallback" to "Div { " + "P { Probe() }; ".repeat(126) + + "Text(\"tail\"); val alias = \"value\"; val second = alias + \"!\"; Span { Text(second); Probe() } }", + "DeepConstant" to "Div { ".repeat(66) + "Text(\"deep\")" + " }".repeat(66), + "MixedLiteralChildren" to "Div(attrs = { id(Mark(name)) }) { Span { Text(\"prefix\") }; P { Probe() }; Span { Text(\"suffix\") } }", + "MixedAdjacentText" to """ + Div(attrs = { + id(Mark(name)) + }) { + Text("first") + Text("") + Text(name) + Text("last") + Span { + Probe() + } + } + """.trimIndent(), + "MixedTable" to "Table(attrs = { id(Mark(name)) }) { Tbody { Tr { Td { Text(name); Probe() } } } }", + "MultipleRuntimeCalls" to "Div { H1 { Text(\"Title\") }; P { Probe() }; Probe(); Span { Probe() }; Probe() }", + "StaticTextAroundTags" to "Div { Text(\"lead\"); P { Probe() }; Text(\"mid\"); Span { Text(\"s\") }; Text(\"tail\") }", + "LargeBatches" to "Div { P { Probe() }; Span { Text(Big) }; P { Probe() }; Span { Text(Big) } }", + "Namespace" to "TagElementNS(\"svg\", \"urn:test\", null) { Div { P { Probe() } }; Probe() }", + "VoidContent" to "TagElement(\"input\", null) { Div { P { Effect() } } }; Span { Probe() }", + "InvalidTableParent" to "Table { Div { P { Boom() } } }", + "ThrowingContent" to "Div { H1 { Text(\"before\") }; P { Boom() }; Effect() }", + "WideSiblings" to "Div { ${(0 until 300).joinToString("; ") { "Span { Text(\"item $it\") }" }}; Text(name) }", + "AbsentContent" to "Div(); Div { Span(); Probe(); Br(); Probe() }; Div(attrs = { id(Mark(name)) })", + "MixedVoid" to """ + Div { + Br(attrs = { id(Mark(name)) }) + Probe() + Input(type = InputType.Text, attrs = { id(Mark(name)) }) + Probe() + } + """.trimIndent(), + "NestedChunkedStatic" to "Div { if (name.isNotEmpty()) { Span { Text(Big) } }; Probe() }", + "MultipleRoots" to "Div { P { Probe() } }; Div { Span { Probe() } }; Text(name)", + "SvgBudgetFallback" to "Svg { " + "G { Probe() }; ".repeat(150) + "Circle(1, 2, 3); Scope(this).Circle(1, 2, 3) }", + "ReceiverCapture" to """ + Div { P { Text(name) }; Keep(this) } + Div { Keep(this); P { Probe() } } + """.trimIndent(), + "SvgRegions" to """ + Div { + Svg(viewBox = "0 0 20 20") { + Defs { LinearGradient(id = "gradient") { Stop() } } + G { Circle(1, 2, 3); Probe() } + Probe() + } + Probe() + } + """.trimIndent(), + ) + private val comparison by lazy { + LoweringComparison( + cases, declarations = """ + private const val Big = "${"y".repeat(20_000)}" + @Composable fun Keep(scope: Any) { Text("scope") } + @Composable fun Effect() { Mark("effect"); Text("effect") } + @Composable fun Boom() { Mark("boom"); error("boom") } + fun Scope(scope: ElementScope): ElementScope { + Mark("scope") + return scope + } + """.trimIndent() + ) + } + } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/ElementLoweringTest.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/ElementLoweringTest.kt new file mode 100644 index 0000000000..c76efc8ab5 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/ElementLoweringTest.kt @@ -0,0 +1,451 @@ +/* + * 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 kotlinx.browser.dom.HTMLDivElement +import kotlinx.browser.dom.HTMLElement +import org.jetbrains.compose.html.compiler.samples.ComposableText +import org.jetbrains.compose.html.compiler.samples.ContainerReturn +import org.jetbrains.compose.html.compiler.samples.ContentReturn +import org.jetbrains.compose.html.compiler.samples.DirectContainer +import org.jetbrains.compose.html.compiler.samples.DirectGreeting +import org.jetbrains.compose.html.compiler.samples.OversizedLiteral +import org.jetbrains.compose.html.compiler.samples.PreparedAttributes +import org.jetbrains.compose.html.compiler.samples.PreparedContainer +import org.jetbrains.compose.html.compiler.samples.PreparedDynamicText +import org.jetbrains.compose.html.compiler.samples.PreparedGetter +import org.jetbrains.compose.html.compiler.samples.PreparedLiteralText +import org.jetbrains.compose.html.compiler.samples.PreparedUser +import org.jetbrains.compose.html.compiler.samples.RememberedText +import org.jetbrains.compose.html.compiler.samples.__htmlAttributes0 +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Iframe +import org.jetbrains.compose.web.dom.Pre +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Table +import org.jetbrains.compose.web.dom.TagElement +import org.jetbrains.compose.web.dom.Text +import org.jetbrains.compose.web.dom.Title +import org.jetbrains.org.objectweb.asm.ClassReader +import org.jetbrains.org.objectweb.asm.MethodVisitor +import org.jetbrains.org.objectweb.asm.Opcodes + +class ElementLoweringTest { + @Test + fun directElementsCallTheBridgeWithoutContentLambdasOrAttributeFields() { + for (method in listOf("DirectGreeting", "DirectContainer")) { + val calls = calls(method) + visitMethod(method, object : MethodVisitor(Opcodes.ASM9) { + override fun visitFieldInsn(opcode: Int, owner: String, name: String, descriptor: String) { + assertFalse(name.startsWith("__htmlAttributes")) + } + }) + assertTrue(calls.containsAll(setOf("openLoweredRegion", "writeText", "closeElement")), "$method: $calls") + assertFalse("appendText" in calls, "$method: $calls") + assertFalse(calls.any { + it in setOf( + "Div", + "Span", + "Text", + "TagElement", + "PreparedTextElement", + "PreparedContainer" + ) + }) + assertFalse(calls.any { it.startsWith("composableLambda") }) + } + } + + @Test + fun composableTextUsesRegionsWithoutPreparedAttributesOrContentLambdas() { + for (method in listOf("ComposableText", "RememberedText")) { + val calls = calls(method) + assertTrue(calls.containsAll(setOf("openLoweredRegion", "Text", "closeElement")), "$method: $calls") + assertFalse( + calls.any { it in setOf("Span", "PreparedTextElement", "PreparedContainer") }, + "$method: $calls" + ) + assertFalse(calls.any { it.startsWith("composableLambda") }, "$method: $calls") + assertTrue(fieldReads("DirectElementsKt", method).isEmpty(), method) + } + assertTrue("" in methodStrings("DirectElementsKt", "ComposableText")) + } + + @Test + fun composableTextPreservesEffectsNestedTextAndDiscardedContent() { + forEachRenderMode { strict, hydratable, stream -> + val effects = mutableListOf() + val boundary = if (hydratable) "" else "" + val expected = "beforeprefix${boundary}<&" + + "rememberedafter${boundary}adjacent" + val actual = render(strict, hydratable, stream) { + Text("before") + ComposableText { + effects.add("value") + Text("prefix") + "<&" + } + RememberedText("remembered") + TagElement("input", null) { + ComposableText { + effects.add("discarded") + Text("discarded") + "discarded" + } + } + Text("after") + Text("adjacent") + } + assertEquals(expected, actual) + assertEquals(listOf("value", "discarded"), effects) + } + } + + @Test + fun specialTagsUseRegionsAndLabeledReturnsKeepPreparedCalls() { + assertTrue("openLoweredRegion" in calls("SpecialText")) + assertFalse("PreparedTextElement" in calls("SpecialText")) + assertTrue(fieldReads("DirectElementsKt", "SpecialText").isEmpty()) + assertTrue("PreparedContainer" in calls("ContainerReturn")) + assertEquals("
shown
", composeHtmlToString { + ContainerReturn(true) {} + ContainerReturn(false) {} + }) + } + + @Test + fun containersPreserveEffectsLoopsConditionsAndTextBoundaries() { + forEachRenderMode { strict, hydratable, stream -> + val effects = mutableListOf() + val content: @Composable () -> Unit = { + Text("before") + DirectContainer(listOf("<&", "", "é😀"), effects::add) + DirectGreeting { + effects.add("greeting") + "dynamic<&" + } + Text("after") + Text("adjacent") + } + val boundary = if (hydratable) "" else "" + val expected = "before
headvalue: <&" + + "emptyvalue: é😀footermiddlesiblingtail
" + + "
dynamic<&
" + + "after${boundary}adjacent" + val actual = render(strict, hydratable, stream, content) + assertEquals(expected, actual) + assertEquals(listOf("open", "footer", "close", "greeting"), effects) + } + } + + @Test + fun staticChildrenMergeIntoOpeningAndClosingTags() { + val constants = methodStrings("DirectElementsKt", "DirectContainer") + assertTrue("
head" in constants) + assertTrue("tail
" in constants) + assertTrue("middlesibling" in constants) + } + + private fun calls(method: String): Set = methodCalls("DirectElementsKt", method).toSet() + + private fun visitMethod(method: String, visitor: MethodVisitor) = + visitSampleMethod("DirectElementsKt", method, visitor) + + @Test + fun selectsExpectedLoweringPathsAndRetainsUnsupportedAttributes() { + val expected = mapOf( + "PreparedAttributes" to "getCallback", + "PreparedContainer" to "PreparedContainer", + "PreparedDynamicText" to "writeText", + "PreparedGetter" to "writeText", + "OversizedLiteral" to "beginStaticHtmlFragment", + "ContentReturn" to "PreparedContainer", + ) + for ((method, helper) in expected) { + val calls = runtimeCalls(method) + assertTrue(helper in calls, "$method: $calls") + if (method != "PreparedAttributes") { + assertFalse("Span" in calls || "Div" in calls, "$method: $calls") + } + } + assertTrue("finishMixedElement" in runtimeCalls("AttributeEffects")) + assertEquals( + setOf("<& 😀"), + staticHtmlConstants("PreparedElementsKt", "PreparedLiteralText"), + ) + } + + @Test + fun oversizedLiteralsStreamInChunksAndFieldNamesDoNotCollide() { + assertEquals("authored", __htmlAttributes0) + val expected = "" + "<".repeat(2_000) + "" + forEachRenderMode { strict, hydratable, stream -> + assertEquals(expected, render(strict, hydratable, stream) { OversizedLiteral() }) + } + } + + @Test + fun gettersRunOncePerRenderAndNeverDuringInitialization() { + var calls = 0 + val user = PreparedUser { "user-${++calls}" } + assertEquals(0, calls) + repeat(2) { render -> + assertEquals("user-${render + 1}", composeHtmlToString { + PreparedGetter(user) + }) + assertEquals(render + 1, calls) + } + } + + @Test + fun preparedAttributesSupportStoredNullableAndEffectfulContent() { + val effects = mutableListOf() + fun content(): ContentBuilder { + effects.add("argument") + return { + effects.add("content") + Text("stored") + } + } + repeat(2) { + assertEquals("
stored
", composeHtmlToString { + PreparedAttributes(content()) + }) + } + assertEquals(listOf("argument", "content", "argument", "content"), effects) + assertEquals("
", composeHtmlToString { + PreparedAttributes(null) + }) + } + + @Test + fun containersAndTextRetainOrderAcrossRendersAndStreams() { + forEachRenderMode { strict, hydratable, stream -> + val effects = mutableListOf() + repeat(2) { render -> + val content: @Composable () -> Unit = { + Text("before") + PreparedContainer("user-$render", effects::add) + PreparedDynamicText { + effects.add("price") + "price-$render" + } + PreparedLiteralText() + Text("after") + } + val expected = "before
user-$renderdetailsend
" + + "price-$render<& 😀after" + assertEquals(expected, render(strict, hydratable, stream, content)) + assertEquals(List(render + 1) { listOf("container", "text", "price") }.flatten(), effects) + } + } + } + + @Test + fun earlyReturnsAndInvalidParentsKeepEvaluationOrder() { + assertEquals("", composeHtmlToString { ContentReturn(true) }) + assertEquals("shown", composeHtmlToString { ContentReturn(false) }) + for (stream in listOf(false, true)) { + val effects = mutableListOf() + assertFailsWith { + render(strict = true, hydratable = true, stream = stream) { + Table { + Span { + Text(run { + effects.add("text") + "invalid child" + }) + } + } + } + } + assertTrue(effects.isEmpty()) + assertFailsWith { + render(strict = true, hydratable = true, stream = stream) { + Div { + effects.add("before") + PreparedDynamicText { + effects.add("text") + error("text failure") + } + effects.add("after") + } + } + } + assertEquals(listOf("before", "text"), effects) + } + } + + @Test + fun textModesAndRuntimeValidationArePreserved() { + for (stream in listOf(false, true)) { + assertEquals( + "
\n\n<&
<&", + render(strict = true, hydratable = true, stream = stream) { + Pre { Text("\n<&") } + Title { Text("<&") } + Iframe { Text("<&") } + Span { Text("") } + }) + for (value in listOf("\n<&", "")) { + assertEquals( + "
${if (value.isEmpty()) "" else "\n\n<&"}
", + render(strict = true, hydratable = true, stream = stream) { Pre { Text(value) } }) + } + assertFailsWith { + render(strict = true, hydratable = true, stream = stream) { PreparedDynamicText { "bad\u0000" } } + } + } + } + + private fun runtimeCalls(method: String): Set = + methodCalls("PreparedElementsKt", method, "org/jetbrains/compose/web/dom/").toSet() + + @Test + fun specialElementsMatchOrdinaryRenderingAndValidation() { + comparison.assertEquivalent() + } + + @Test + fun specialElementsUseWriterRegionsWithoutPreparedCalls() { + for (tag in tags) { + val method = methodName(tag) + val calls = classMethodCalls(comparison.bytecode).getValue(method) + assertTrue("openLoweredRegion" in calls, "$method: $calls") + assertTrue("closeElement" in calls, "$method: $calls") + assertFalse(calls.any { it in setOf("TagElement", "PreparedContainer", "PreparedTextElement") }, method) + } + } + + @Test + fun writerRegionsUsePrecomputedNewlineFlags() { + val path = "/org/jetbrains/compose/web/dom/HtmlStringWriterContext.class" + val bytecode = requireNotNull(javaClass.getResourceAsStream(path)).use { ClassReader(it.readBytes()) } + val calls = classMethodCalls(bytecode) + for (method in listOf("openLoweredRegion", "advanceLoweredRegion", "finishMixedElement")) { + assertFalse("isHtmlLeadingNewlineElement" in calls.getValue(method), method) + } + } + + companion object { + private val tags = listOf( + "pre", "textarea", "listing", "title", + "table", "caption", "colgroup", "thead", "tbody", "tfoot", "tr", "td", "th", + ) + + private fun methodName(tag: String): String = "Text" + tag.replaceFirstChar { it.uppercase() } + + private val comparison by lazy { + val cases = tags.associate { tag -> + methodName(tag) to """ + TagElement("$tag", { classes("constant") }) { + Text("") + Text("\n") + Text(Mark(name)) + Text("tail") + } + Text("after") + Text("adjacent") + """.trimIndent() + } + mapOf( + "MixedLeadingNewline" to """ + Pre(attrs = { attr("title", Mark(name)) }) { + Text("") + Text(Mark("\n" + name)) + } + """.trimIndent(), + "ForeignLeadingNewlineNames" to """ + TagElementNS("pre", "urn:test", null) { Text(Mark("\n" + name)) } + TagElementNS("textarea", "urn:test", { attr("title", name) }) { + Text(Mark("\n" + name)) + } + """.trimIndent(), + "ChunkedSpecialOpening" to """ + Pre(attrs = { attr("title", "${"x".repeat(9_000)}") }) { + Text(Mark("\n" + name)) + } + """.trimIndent(), + "LeadingNewlineAfterEmptyText" to """ + Pre { + Text("") + Text(Mark("\n" + name)) + Text("tail") + } + """.trimIndent(), + "NestedSpecialElements" to """ + Div { + Pre { + Text(Mark("\n" + name)) + Span { Text(name) } + Text("tail") + } + Title { + Text("first") + Text(name) + } + TagElement("textarea", { attr("title", name) }) { + Text("") + Text(Mark("\n" + name)) + Text("tail") + } + } + """.trimIndent(), + "TableValidationOrder" to """ + Table { + Text(Mark(" ")) + Span { Text(Mark(name)) } + } + """.trimIndent(), + "TableTextAfterStaticChild" to """ + Table { + Tbody { + Tr { + Td { Text("constant") } + Text(Mark(name)) + } + } + } + """.trimIndent(), + "TableCells" to """ + Table { + Tbody { + Tr { + Td { Text(Mark(name)) } + Th { Text(name) } + } + } + } + """.trimIndent(), + "DiscardedSpecialElements" to """ + TagElement("input", null) { + Pre { Text(Mark("\n" + name)) } + Table { Tr { Text(Mark(name)) } } + } + """.trimIndent(), + "ComposableSpecialText" to """ + Pre { Text(Value(name)) } + Title { Text(Value(name)) } + """.trimIndent(), + ) + LoweringComparison( + cases, declarations = """ + @Composable private fun Value(name: String): String { + Text("prefix") + return Mark("\n" + name) + } + """.trimIndent() + ) + } + } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/GenerateFixture.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/GenerateFixture.kt new file mode 100644 index 0000000000..64341f022c --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/GenerateFixture.kt @@ -0,0 +1,29 @@ +/* + * 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 org.jetbrains.compose.html.compiler.samples.LargeStaticHydration +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.composeHtmlToStream +import java.io.File + +fun main(args: Array) { + val target = File(args.single(), "CompilerHydrationFixture.kt").apply { parentFile.mkdirs() } + target.writeText(buildString { + appendLine("package org.jetbrains.compose.html.compiler") + val html = composeHtmlToString { CompilerHydrationContent(0) {} } + appendLine("val jvmHydrationHtml = ${html.kotlinLiteral()}") + val prepared = composeHtmlToString { PreparedHydrationContent(0, {}) } + appendLine("val jvmPreparedHydrationHtml = ${prepared.kotlinLiteral()}") + val large = StringBuilder() + composeHtmlToStream(chunkSize = 1, sink = { large.append(it) }) { LargeStaticHydration(0, {}) } + appendLine("val jvmLargeHydrationHtml = ${large.toString().kotlinLiteral()}") + }) +} + +private fun String.kotlinLiteral(): String = "\"" + replace("\\", "\\\\") + .replace("\"", "\\\"").replace("$", "\\$") + .replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t") + "\"" diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/HtmlLoweringTest.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/HtmlLoweringTest.kt new file mode 100644 index 0000000000..7b2c72899d --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/HtmlLoweringTest.kt @@ -0,0 +1,290 @@ +/* + * 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.compose.web.internal.runtime.ComposeWebInternalApi::class) + +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.assertSame +import kotlinx.browser.dom.HTMLElement +import org.jetbrains.compose.html.compiler.samples.SharedPage +import org.jetbrains.compose.html.compiler.samples.UserDefinedPage +import org.jetbrains.compose.html.compiler.samples.customTagCalls +import org.jetbrains.compose.html.compiler.samples.sharedContent +import org.jetbrains.compose.web.composeHtmlToStream +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.dom.* + +private val htmlAtInitialization = composeHtmlToString { Span { Text("initialized") } } + +class HtmlLoweringTest { + @Test + fun standaloneFunctionsAndStoredContentFollowTheCompilerOption() { + val fields = Class.forName("${javaClass.packageName}.samples.CrossFileSupportKt").declaredFields + assertFalse(fields.any { it.name.startsWith("__html") }) + assertEquals( + setOf("shared lambda", "shared function"), + staticHtmlConstants("CrossFileSupportKt"), + ) + assertEquals("initialized", htmlAtInitialization) + assertEquals("shared lambda", composeHtmlToString(content = sharedContent)) + assertEquals("shared function", composeHtmlToString(content = ::SharedPage)) + + val page: @Composable () -> Unit = { Div { Span { Text("<&é😀") } } } + val html = composeHtmlToString(content = page) + assertEquals("
<&é😀
", html) + assertSame(html, composeHtmlToString(content = page)) + } + + @Test + fun dynamicParentsRetainEffectsAndStaticChildren() { + var calls = 0 + repeat(2) { + val html = composeHtmlToString { + Div(attrs = { id("value-${++calls}") }) { + Span { Text("constant") } + Text("value-$calls") + } + } + assertEquals(it + 1, calls) + assertEquals("
constantvalue-$calls
", html) + } + } + + @Test + fun nestedAdjacentTextKeepsHydrationBoundariesInLoweredFragments() { + for (hydratable in listOf(false, true)) { + val boundary = if (hydratable) "" else "" + val nestedBoundary = "" + val expected = "
beforefirst${nestedBoundary}secondafter${nestedBoundary}tail
" + assertEquals(expected, composeHtmlToString(hydratable = hydratable) { AdjacentTextPage() }) + val chunks = StringBuilder() + composeHtmlToStream(hydratable = hydratable, chunkSize = 1, sink = { chunks.append(it) }) { + Text("head") + AdjacentTextPage() + Text("end") + Text("next") + } + assertEquals("head${expected}end${boundary}next", chunks.toString()) + } + } + + @Test + fun similarlyNamedWrappersKeepTheirRuntimeEffects() { + repeat(2) { + val before = customTagCalls + assertEquals("
text
", composeHtmlToString { UserDefinedPage() }) + assertEquals(before + 1, customTagCalls) + } + @Composable + fun Span(content: @Composable () -> Unit) { + Text("local:") + content() + } + assertEquals("local:text", composeHtmlToString { Span { Text("text") } }) + } + + @Test + fun invalidTextFailsDuringRendering() { + assertFailsWith { composeHtmlToString { Span { Text("bad\u0000") } } } + } + + @Test + fun completeTablesAndTheirParentsUseOneStaticFragment() { + val samples = mapOf( + "ConstantTable" to tableHtml(""), + "NestedConstantTable" to "
before
cell
after
", + "EmptyConstantTable" to "" + + "
", + ) + for ((method, html) in samples) { + assertEquals(setOf(html), staticHtmlConstants("CompilerHydrationKt", method), method) + val calls = methodCalls("CompilerHydrationKt", method) + assertEquals(1, calls.count { it == "beginStaticHtml" }, method) + assertFalse(calls.any { + it in setOf( + "Table", + "Colgroup", + "Thead", + "Tbody", + "Tfoot", + "Tr", + "Td", + "Th", + "Text" + ) + }, method) + } + } + + @Test + fun tablesPreserveRenderingAndValidateTheirRuntimeParent() { + forEachRenderMode { strict, hydratable, stream -> + val expected = tableHtml("") + assertEquals(expected, render(strict, hydratable, stream) { ConstantTable() }) + val invalid: @Composable () -> Unit = { Table { ConstantTable() } } + if (strict) { + assertFailsWith { render(strict, hydratable, stream, invalid) } + } else { + assertEquals("$expected
", render(strict, hydratable, stream, invalid)) + } + } + } + + @Test + fun aliasesAndVoidAttributesSerializeCorrectly() { + assertHtml("
term
<&
map
") { + Article { + DList { DTerm { Text("term") }; DDescription { Text("<&") } } + HTMLMap { Text("map") } + } + } + assertHtml( + "
" + + "
" + ) { + Div { + Meta(attrs = { attr("name", "description"); attr("content", "Hello & goodbye") }) + Link(attrs = { attr("rel", "stylesheet"); attr("href", "/style.css") }) + } + } + assertHtml("") { + Script(attrs = { attr("src", "/app.js?a=1&b=2"); attr("defer", "") }) + } + assertHtml("") { + Track(attrs = { attr("default", "yes"); attr("kind", "captions") }) + } + } + + @Test + fun tableContainersAndStandaloneRowsKeepTheirContent() { + assertHtml("heading") { Th { Text("heading") } } + assertHtml("value") { Td(attrs = { attr("title", "cell") }) { Text("value") } } + assertHtml("cell") { Row() } + assertHtml("
cell
") { + Table { Colgroup { Col() }; Tbody { Text(" "); Row() } } + } + } + + @Test + fun textModesPreserveEscapingBoundariesAndLeadingNewlines() { + assertHtml("
\n\n<&
") { Pre { Text(""); Text("\n<&") } } + assertHtml("

\ntext
") { Pre { Br(); Text("\ntext") } } + assertHtml("<&title") { Title { Text("<&"); Text("title") } } + assertHtml("") { Iframe { Text("&\r"); Text("\ncontent\u0000") } } + assertHtml("") { Iframe { Text("") } } + assertHtml("") { Iframe { Text("") } } + assertHtml("") { Script(InlineScript("a < b && c > d")) } + assertHtml("") { Noscript { Text("fallback") } } + } + + @Test + fun specialTextAndDiscardedVoidContentKeepRuntimeEffects() { + for (value in listOf("first", "second")) { + assertHtml("
$value
$value
") { + Div { Pre { Text(value) }; Title { Text(value) }; Iframe { Text(value) } } + } + } + var calls = 0 + repeat(2) { + assertHtml("") { Area { calls++; Text("discarded") } } + assertEquals(it + 1, calls) + } + } + + @Test + fun invalidStructureFailsDuringRendering() { + val invalid = listOf<@Composable () -> Unit>( + { Table { Row() } }, + { Table { Text("invalid") } }, + { Colgroup { Div() } }, + { Tbody { Td { Text("invalid") } } }, + { Tr { Text("invalid") } }, + { Tr { Div() } }, + { Iframe { Text("") } }, + { Iframe { Div() } }, + { Script(InlineScript("")) }, + { InvalidRoot { Text("text") } }, + { Noscript { Noscript { Text("nested") } } }, + ) + for (page in invalid) { + assertFailsWith { composeHtmlToString(validateStrictly = true, content = page) } + } + } + + @Test + fun tableParentChecksOnlyRunInStrictModeForStaticAndDynamicChildren() { + for (stream in listOf(false, true)) { + for (dynamic in listOf(false, true)) { + val page: @Composable () -> Unit = { + Table { + if (dynamic) DynamicRow("cell") else Row() + } + } + + fun render(strict: Boolean): String { + if (!stream) return composeHtmlToString(validateStrictly = strict, content = page) + val output = StringBuilder() + org.jetbrains.compose.web.composeHtmlToStream( + sink = { output.append(it) }, chunkSize = 1, + validateStrictly = strict, content = page + ) + return output.toString() + } + assertEquals("
cell
", render(false)) + assertFailsWith { render(true) } + } + } + } + + private fun assertHtml(expected: String, page: @Composable () -> Unit) { + assertEquals(expected, composeHtmlToString(content = page)) + } + + private fun tableHtml(boundary: String): String { + val row = "a<&${boundary}b" + + "
nested
" + return "" + + "\n" + row.repeat(2) + + "
Results
Name
Total
" + } +} + +@Composable +private fun AdjacentTextPage() { + Div { + Text("before") + Span { Text(""); Text("first"); Text(""); Text("second") } + Text("after") + Text("tail") + } +} + +@Composable +private fun Row() { + Tr { Td { Text("cell") } } +} + +@Composable +private fun DynamicRow(value: String) { + Tr { Td { Text(value) } } +} + +@Composable +@LowerableHtmlTag("bad tag") +private fun InvalidRoot(attrs: AttrBuilderContext? = null, content: ContentBuilder? = null) { + TagElement("bad tag", attrs, content) +} + +@Composable +@LowerableHtmlTag("noscript") +private fun Noscript(attrs: AttrBuilderContext? = null, content: ContentBuilder? = null) { + TagElement("noscript", attrs, content) +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/LoweringLimitsTest.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/LoweringLimitsTest.kt new file mode 100644 index 0000000000..2730e533ba --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/LoweringLimitsTest.kt @@ -0,0 +1,331 @@ +/* + * 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 java.io.ByteArrayOutputStream +import java.io.OutputStreamWriter +import java.lang.reflect.Modifier +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.jetbrains.compose.html.compiler.samples.InvalidLargeRawFragment +import org.jetbrains.compose.html.compiler.samples.LargeDirectAttribute +import org.jetbrains.compose.html.compiler.samples.LargeLeadingNewlineFragment +import org.jetbrains.compose.html.compiler.samples.LargePreparedAttribute +import org.jetbrains.compose.html.compiler.samples.LargeRawFragment +import org.jetbrains.compose.html.compiler.samples.LargeStaticPage +import org.jetbrains.compose.html.compiler.samples.LargeTableFragment +import org.jetbrains.compose.html.compiler.samples.LargeUnicodeFragment +import org.jetbrains.compose.web.composeHtmlToStream +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.dom.* +import org.jetbrains.compose.web.renderHydratedDocument +import org.jetbrains.compose.web.renderHydratedDocumentToStream +import org.jetbrains.org.objectweb.asm.ClassReader +import org.jetbrains.org.objectweb.asm.ClassVisitor +import org.jetbrains.org.objectweb.asm.MethodVisitor +import org.jetbrains.org.objectweb.asm.Opcodes + +class LoweringLimitsTest { + @Test + fun smallPagesKeepTheSingleStringFastPath() { + val page: @Composable () -> Unit = { Html { Body { Text("small") } } } + val html = composeHtmlToString(content = page) + assertEquals("small", html) + assertSame(html, composeHtmlToString(content = page)) + } + + @Test + fun literalAppendsBeginEachLogicalFragmentOnceWithoutFieldsOrJoins() { + val fields = Class.forName("${javaClass.packageName}.samples.LargeFragmentKt").declaredFields + assertFalse(fields.any { it.name.startsWith("__html") }) + for (method in listOf( + "LargeUnicodeFragment", "LargeStaticPage", "LargeRawFragment", + "LargeLeadingNewlineFragment", "LargeTableFragment", "LargeTableRowFragment" + )) { + val calls = mutableListOf() + val literals = mutableListOf() + sampleBytecode("LargeFragmentKt").accept(object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array? + ): MethodVisitor? { + if (name != method) return null + return object : MethodVisitor(Opcodes.ASM9) { + override fun visitLdcInsn(value: Any) { + if (value is String) literals.add(value) + } + + override fun visitMethodInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean + ) { + calls.add(name) + } + + override fun visitIntInsn(opcode: Int, operand: Int) { + assertFalse(opcode == Opcodes.NEWARRAY, method) + } + + override fun visitTypeInsn(opcode: Int, type: String) { + assertFalse(opcode == Opcodes.ANEWARRAY, method) + } + } + } + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + assertEquals(1, calls.count { it == "beginStaticHtmlFragment" }, "$method: $calls") + assertTrue(calls.count { it == "append" } >= 2, method) + assertEquals(calls.count { it == "append" }, calls.count { it == "flushChunk\$default" }, method) + assertFalse(calls.any { + it in setOf( + "toString", + "joinToString", + "WriteStaticHtml", + "appendStaticHtmlChunk" + ) + }, method) + assertTrue(literals.all { it.length <= 8_000 }, method) + } + } + + @Test + fun unicodeAndTextStateMatchForStringsAndIndependentlyEncodedStreamChunks() { + for (strict in listOf(false, true)) { + for (hydratable in listOf(false, true)) { + val boundary = "" + val unicode = "
" + "x".repeat(7_994) + "😀<& ${boundary}tailchild
" + val raw = "" + val pre = "
\n\n" + "x".repeat(7_994) + "😀
" + val table = "
" + "x".repeat(7_994) + "😀
" + val cases = listOf Unit>>( + unicode to { LargeUnicodeFragment() }, raw to { LargeRawFragment() }, + pre to { LargeLeadingNewlineFragment() }, table to { LargeTableFragment() }, + ) + for ((expected, content) in cases) { + assertEquals( + expected, + composeHtmlToString(validateStrictly = strict, hydratable = hydratable, content = content) + ) + for (chunkSize in listOf(1, 2_048, 8_000, 8_001)) { + val bytes = ByteArrayOutputStream() + composeHtmlToStream( + validateStrictly = strict, hydratable = hydratable, chunkSize = chunkSize, + sink = { bytes.write(it.toByteArray(Charsets.UTF_8)) }, content = content + ) + assertEquals(expected, bytes.toString(Charsets.UTF_8), "chunkSize=$chunkSize") + } + val bytes = ByteArrayOutputStream() + OutputStreamWriter(bytes, Charsets.UTF_8).use { writer -> + composeHtmlToStream( + writer, validateStrictly = strict, hydratable = hydratable, + chunkSize = 1, content = content + ) + } + assertEquals(expected, bytes.toString(Charsets.UTF_8)) + } + val outerBoundary = if (hydratable) "" else "" + assertEquals("head${unicode}after${outerBoundary}tail", composeHtmlToString(hydratable = hydratable) { + Text("head") + LargeUnicodeFragment() + Text("after") + Text("tail") + }) + } + } + } + + @Test + fun largePagesCountAsOneDocumentRootAndStreamWithoutJoining() { + val expected = + "
" + ("" + "界".repeat(3_000) + "").repeat(30) + "
" + assertEquals(expected, composeHtmlToString { LargeStaticPage() }) + assertEquals("$expected", renderHydratedDocument(validateStrictly = true) { LargeStaticPage() }) + val chunks = mutableListOf() + renderHydratedDocumentToStream(validateStrictly = true, chunkSize = 1, sink = chunks::add) { LargeStaticPage() } + assertEquals("$expected", chunks.joinToString("")) + assertTrue(chunks.all { it.length <= 8_000 }) + for (stream in listOf(false, true)) { + assertFailsWith { + if (stream) renderHydratedDocumentToStream(validateStrictly = true, sink = {}) { + LargeStaticPage() + LargeStaticPage() + } else renderHydratedDocument(validateStrictly = true) { + LargeStaticPage() + LargeStaticPage() + } + } + } + } + + @Test + fun validatesCompleteRawTextAndParentBeforeStreamingTheFragment() { + for (strict in listOf(false, true)) { + val chunks = mutableListOf() + assertFailsWith { + composeHtmlToStream( + validateStrictly = strict, + chunkSize = 1, + sink = chunks::add + ) { InvalidLargeRawFragment() } + } + assertTrue(chunks.isEmpty()) + } + for (stream in listOf(false, true)) { + assertFailsWith { + val content: @Composable () -> Unit = { Table { LargeUnicodeFragment() } } + if (stream) composeHtmlToStream(validateStrictly = true, chunkSize = 1, sink = {}, content = content) + else composeHtmlToString(validateStrictly = true, content = content) + } + } + assertEquals("after", composeHtmlToString { + Area { LargeUnicodeFragment() } + Span { Text("after") } + }) + } + + @Test + fun singleStringConsumersCacheBackendSplitLiteralsInPrivateFinalFields() { + val title = "界".repeat(24_000) + val expected = "value" + for (content in listOf<@Composable () -> Unit>( + { LargeDirectAttribute("value") }, { LargePreparedAttribute { Text("value") } }, + )) { + assertEquals(expected, composeHtmlToString(content = content)) + val bytes = ByteArrayOutputStream() + composeHtmlToStream( + chunkSize = 1, + sink = { bytes.write(it.toByteArray(Charsets.UTF_8)) }, + content = content + ) + assertEquals(expected, bytes.toString(Charsets.UTF_8)) + } + val fields = Class.forName("${javaClass.packageName}.samples.LargeAttributeKt").declaredFields + .filter { it.name.startsWith("__htmlString") } + assertEquals(2, fields.size) + for (field in fields) { + assertEquals(String::class.java, field.type) + assertTrue( + Modifier.isPrivate(field.modifiers) && Modifier.isStatic(field.modifiers) && Modifier.isFinal( + field.modifiers + ) + ) + field.isAccessible = true + assertTrue((field.get(null) as String).length >= 24_000) + } + } + + @Test + fun evaluationAndEncodedOutputBudgetsRemainIndependentOfChunkSize() { + val seed = "xxxxxxxxxxxxxxxx" + val a = seed + seed + val b = a + a + val c = b + b + val d = c + c + val e = d + d + val f = e + e + val g = f + f + val h = g + g + val i = h + h + val j = i + i + val k = j + j + val l = k + k + val m = l + l + val n = m + m + val o = n + n + val p = o + o + // More than the evaluation budget: retain ordinary text evaluation. + assertEquals(1_048_589, composeHtmlToString { Span { Text(p) } }.length) + // Escaping crosses the encoding budget even though expansion has only 2,000 calls. + val escaped = composeHtmlToString { Div { repeat(2_000) { Span { Text(EscapingText) } } } } + assertEquals(2_074_011, escaped.length) + assertTrue(escaped.startsWith("
<<")) + } + + @Test + fun functionAndFileBudgetsFallBackWithoutChangingOutput() { + val calls = classMethodCalls(comparison.bytecode("sample.BudgetKt")) + for (index in 0..8) { + assertEquals( + if (index < 8) 64 else 0, + calls.getValue("BudgetFragment$index").count { it == "beginStaticHtmlFragment" }) + } + val expected = ("" + "x".repeat(8_001) + "").repeat(65) + for (index in listOf(0, 7, 8)) { + val name = "BudgetCase$index" + comparison.assertEquivalent(listOf(name)) + assertEquals("$expected|events=[]", comparison.ordinary.getValue(name)) + } + } + + @Test + fun regionsKeepTheirOuterPlanWhenChildrenExceedBudgets() { + val calls = classMethodCalls(comparison.bytecode("sample.RegionBudgetKt")) + for (method in listOf("RegionChunkBudget", "RegionLengthBudget")) { + val methodCalls = calls.getValue(method) + val batches = methodCalls.count { it in setOf("openLoweredRegion", "advanceLoweredRegion", "closeElement") } + assertEquals(1, methodCalls.count { it == "openLoweredRegion" }, method) + assertEquals(1, methodCalls.count { it == "closeElement" }, method) + assertTrue(batches in 2..128, "$method: $batches") + if (method == "RegionChunkBudget") { + assertEquals(128, batches) + assertTrue("PreparedTextElement" in methodCalls) + } + } + comparison.assertEquivalent(listOf("ChunkBudget", "LengthBudget")) + assertEquals( + "
" + "<&".repeat(150) + "
|events=[]", + comparison.ordinary.getValue("ChunkBudget") + ) + assertEquals( + "
" + ("" + "x".repeat(8_000) + "").repeat(125) + "
|events=[]", + comparison.ordinary.getValue("LengthBudget") + ) + } + + companion object { + private val comparison by lazy { + val imports = + "package sample\nimport androidx.compose.runtime.Composable\nimport org.jetbrains.compose.web.dom.*\n" + // LoweringLimits.kt: cross 8_000-char chunks, 128/1_024-chunk method/file caps and the 1_000_000-char fragment cap. + val fragments = (0..8).joinToString("\n") { index -> + "@Composable fun BudgetFragment$index() {\n" + "Span { Text(BudgetText) }\n".repeat(65) + "}" + } + LoweringComparison( + mapOf( + "BudgetCase0" to "BudgetFragment0()", + "BudgetCase7" to "BudgetFragment7()", + "BudgetCase8" to "BudgetFragment8()", + "ChunkBudget" to "RegionChunkBudget(\"<&\")", + "LengthBudget" to "RegionLengthBudget()", + ), + matrixCases = emptySet(), + extraSources = mapOf( + "Budget.kt" to imports + "private const val BudgetText = \"${"x".repeat(8_001)}\"\n" + fragments, + "RegionBudget.kt" to imports + "private const val RegionText = \"${"x".repeat(8_000)}\"\n" + + "@Composable fun RegionChunkBudget(value: String) { Div {\n" + + "Span { Text(value) }\n".repeat(150) + "} }\n" + + "@Composable fun RegionLengthBudget() { Div {\n" + + "Span { Text(RegionText) }\n".repeat(125) + "} }\n", + ), + ) + } + } +} + +private const val EscapingText = "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" + + "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" + "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" + "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" + + "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" + "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" + "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" + + "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/MixedAttributeLoweringTest.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/MixedAttributeLoweringTest.kt new file mode 100644 index 0000000000..f8e9b7ff3d --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/MixedAttributeLoweringTest.kt @@ -0,0 +1,349 @@ +/* + * 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 org.jetbrains.compose.html.compiler.samples.MixedBranches +import org.jetbrains.compose.html.compiler.samples.MixedButton +import org.jetbrains.compose.html.compiler.samples.MixedCard +import org.jetbrains.compose.html.compiler.samples.MixedControlledInput +import org.jetbrains.compose.html.compiler.samples.MixedEncodedClasses +import org.jetbrains.compose.html.compiler.samples.MixedFallback +import org.jetbrains.compose.html.compiler.samples.MixedFractionalStyle +import org.jetbrains.compose.html.compiler.samples.MixedInput +import org.jetbrains.compose.html.compiler.samples.MixedInputMemberAttributes +import org.jetbrains.compose.html.compiler.samples.MixedInputStyle +import org.jetbrains.compose.html.compiler.samples.MixedInvalid +import org.jetbrains.compose.html.compiler.samples.MixedLiteralPrefix +import org.jetbrains.compose.html.compiler.samples.MixedOverwrite +import org.jetbrains.compose.html.compiler.samples.MixedSelectStyle +import org.jetbrains.compose.html.compiler.samples.MixedStyleBranches +import org.jetbrains.compose.html.compiler.samples.MixedStyleFallback +import org.jetbrains.compose.html.compiler.samples.MixedStyleOverride +import org.jetbrains.compose.html.compiler.samples.MixedTooltip +import org.jetbrains.compose.web.composeHtmlToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MixedAttributeLoweringTest { + @Test + fun unconditionalLiteralPrefixJoinsOpeningTag() { + forEachRenderMode { strict, hydration, stream -> + for (selected in listOf(false, true)) { + assertEquals( + "", + render(strict, hydration, stream) { MixedLiteralPrefix(selected) } + ) + } + } + val strings = methodStrings("MixedAttributesKt", "MixedLiteralPrefix") + assertTrue("
+ for (selected in listOf(false, true)) { + val effects = mutableListOf() + val actual = render(strict, hydration, stream) { + MixedCard("<&é😀", selected) { + effects.add(it) + "$it<&\"\r" + } + MixedButton(selected, "<&") + } + val optional = if (selected) " aria-current=\"current<&" \"" else "" + val disabled = if (selected) " disabled" else "" + assertEquals( + "
body<&\"
" + + "<&", + actual + ) + assertEquals( + if (selected) listOf("id", "current", "data", "body") else listOf("id", "data", "body"), + effects + ) + } + } + } + + @Test + fun overwritesRetainFirstInsertionAndExplicitClassOverridesMerging() { + for (first in listOf(false, true)) { + for (explicit in listOf(false, true)) { + val effects = mutableListOf() + val actual = composeHtmlToString(validateStrictly = true) { + MixedOverwrite(first, explicit, "dynamic") { + effects.add(it) + it + } + } + val attributes = + if (first) " title=\"last\" aria-label=\"label\"" else " aria-label=\"label\" title=\"last\"" + assertEquals( + "body
", + actual + ) + assertEquals( + (if (first) listOf("first") else emptyList()) + listOf("label", "last") + if (explicit) listOf( + "class" + ) else emptyList(), effects + ) + } + } + } + + @Test + fun invalidClassTokensStillValidateWhenOverridden() { + for (explicit in listOf(false, true)) { + for (value in listOf("", "two tokens", "line\nbreak")) { + assertFailsWith { + composeHtmlToString(validateStrictly = true) { MixedInvalid(value, explicit) } + } + assertEquals( + "
body
", + composeHtmlToString { MixedInvalid(value, explicit) } + ) + } + } + } + + @Test + fun duplicateClassesPreserveOrderAndStrictValidation() { + for (stream in listOf(false, true)) { + for (explicit in listOf(false, true)) { + val expectedClass = if (explicit) "class" else "card card" + assertEquals( + "
body
", + render(false, true, stream) { MixedOverwrite(false, explicit, "card") { it } } + ) + assertFailsWith { + render(true, true, stream) { MixedOverwrite(false, explicit, "card") { it } } + } + assertEquals( + "
body
", + render(false, true, stream) { MixedCard("selected", true) { it } } + ) + assertFailsWith { + render(true, true, stream) { MixedCard("selected", true) { it } } + } + } + } + } + + @Test + fun literalAndDynamicClassesUseTheSameEscapingAndValidation() { + for (selected in listOf(false, true)) { + for (stream in listOf(false, true)) { + assertEquals( + "
body
", + render(true, true, stream) { MixedEncodedClasses("c<&\"", selected) } + ) + assertFailsWith { + render(true, true, stream) { MixedEncodedClasses("a<&\"", selected) } + } + } + } + } + + @Test + fun generatedCodeWritesValuesDirectlyAndCallbacksKeepTheBuilder() { + for (method in listOf("MixedCard", "MixedOverwrite", "MixedButton", "MixedBranches")) { + val calls = calls(method) + assertTrue("writeMixedAttribute" in calls, "$method: $calls") + assertFalse(calls.any { + it in setOf( + "Div", + "Button", + "attr", + "classes", + "TagElement" + ) + }, "$method: $calls") + } + assertTrue("Div" in calls("MixedFallback")) + assertEquals("
body
", composeHtmlToString { + MixedFallback { + id("callback") + attr("title", "ignored") + } + }) + } + + @Test + fun inputsPreserveConditionalBooleanAttributesAndNullableValues() { + val variants = listOf(false, true) + for (value in listOf(null, "<&")) { + for (required in variants) { + for (disabled in variants) { + for (checked in variants) { + for (stream in variants) { + val effects = mutableListOf() + val actual = render(true, true, stream) { + MixedInput(value, required, disabled, checked) { + effects.add(it) + it + } + } + val attributes = (if (value == null) "" else " value=\"<&\"") + + (if (required) " required" else "") + + (if (disabled) " disabled" else "") + + (if (checked) " checked" else "") + assertEquals( + "", + actual + ) + assertEquals(if (value == null) listOf("id") else listOf("id", value), effects) + } + } + } + } + } + assertTrue("writeMixedAttribute" in calls("MixedInput")) + for (checked in listOf(false, true)) { + assertEquals( + "", + composeHtmlToString { MixedControlledInput(checked) }) + } + assertTrue("writeMixedAttribute" in calls("MixedControlledInput")) + } + + @Test + fun delegatedMemberAttributesPreserveTypeAndAttributeEvaluationOrder() { + forEachRenderMode { strict, hydration, stream -> + val effects = mutableListOf() + assertEquals( + "", + render(strict, hydration, stream) { + MixedInputMemberAttributes("<&") { + effects.add(it) + it + } + } + ) + assertEquals(listOf("type", "title", "lang", "inputmode"), effects) + } + assertTrue("writeMixedAttribute" in calls("MixedInputMemberAttributes")) + } + + @Test + fun branchResultsAndLocalAliasesRemainValidIr() { + for (selected in listOf(false, true)) { + assertEquals( + "
body
", + composeHtmlToString { MixedBranches("value", selected) }) + } + } + + @Test + fun stylesUseRuntimeCssSerializationAndAttributeEscaping() { + forEachRenderMode { strict, hydration, stream -> + for (reset in listOf(false, true)) { + val color = if (reset) "blue" else "red !important; display: none" + assertEquals( + "tooltip" + + "
", + render(strict, hydration, stream) { + MixedTooltip("tt-<&") + MixedFractionalStyle(0.25, reset) + } + ) + } + } + for (method in listOf( + "MixedTooltip", + "MixedFractionalStyle", + "MixedStyleBranches", + "MixedStyleOverride", + "MixedInputStyle", + "MixedSelectStyle" + )) { + val calls = calls(method) + assertTrue("writeMixedStyle" in calls, "$method: $calls") + assertFalse( + calls.any { it in setOf("Span", "Div", "Input", "Select", "style", "TagElement") }, + "$method: $calls" + ) + } + } + + @Test + fun styleBlocksPreserveOrderAndConditionalExecution() { + forEachRenderMode { strict, hydration, stream -> + for (selected in listOf(false, true)) { + for (value in listOf(null, "label")) { + val effects = mutableListOf() + val display = if (selected) "; display: none" else "" + val variable = if (value == null) "" else "; --label: label" + assertEquals( + "
", + render(strict, hydration, stream) { + MixedStyleBranches(selected, value) { + effects.add(it) + it + } + } + ) + assertEquals( + listOf("red", "title", "blue") + + (if (selected) listOf("none") else emptyList()) + listOfNotNull(value), + effects + ) + } + } + } + } + + @Test + fun explicitStyleOverridesStillExecuteStyleBlocks() { + forEachRenderMode { strict, hydration, stream -> + for (before in listOf(false, true)) { + for (explicit in listOf(false, true)) { + val effects = mutableListOf() + assertEquals( + "
", + render(strict, hydration, stream) { + MixedStyleOverride(before, explicit) { + effects.add(it) + it + } + } + ) + assertEquals( + when { + !explicit -> listOf("red") + before -> listOf("override", "red") + else -> listOf("red", "override") + }, + effects + ) + } + } + } + } + + @Test + fun styleExpressionsSupportSpecializedScopesAndReceiverCaptureFallsBack() { + forEachRenderMode { strict, hydration, stream -> + for (empty in listOf(false, true)) { + val style = if (empty) "" else " style=\"display: none\"" + assertEquals("", render(strict, hydration, stream) { + MixedInputStyle { if (!empty) property("display", "none") } + MixedSelectStyle { if (!empty) property("display", "none") } + }) + } + assertEquals("
", render(strict, hydration, stream) { + MixedStyleFallback("<&") + }) + } + assertTrue("Div" in calls("MixedStyleFallback")) + } + + private fun calls(method: String): Set = methodCalls("MixedAttributesKt", method).toSet() +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/RuntimeDiagnosticsTest.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/RuntimeDiagnosticsTest.kt new file mode 100644 index 0000000000..3c33071bbf --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/RuntimeDiagnosticsTest.kt @@ -0,0 +1,80 @@ +/* + * 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 org.jetbrains.kotlin.cli.common.ExitCode +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RuntimeDiagnosticsTest { + @Test + fun missingRuntimeRespectsExplicitOptInAndOptOut() { + for (enabled in listOf(null, true, false)) { + val (exit, output) = compile("fun answer() = 42", enabled) + assertEquals(if (enabled == true) ExitCode.COMPILATION_ERROR else ExitCode.OK, exit, output) + if (enabled == false) { + assertFalse("[compose-html-lowering]" in output, output) + } else { + assertTrue((if (enabled == true) "error:" else "warning:") in output, output) + assertTrue("org.jetbrains.compose.web.dom.WriteStaticHtml" in output, output) + } + } + } + + @Test + fun incompatibleRuntimeNamesTheMissingFunction() { + val (exit, output) = compile( + "package org.jetbrains.compose.web.dom\nfun UnrelatedRuntimeHelper() {}", enabled = true, + ) + assertEquals(ExitCode.COMPILATION_ERROR, exit, output) + assertTrue("org.jetbrains.compose.web.dom.WriteStaticHtml" in output, output) + } + + @Test + fun incompatibleRuntimeReportsAllMissingDeclarationsTogether() { + val (exit, output) = compile("fun answer() = 42", enabled = true) + assertEquals(ExitCode.COMPILATION_ERROR, exit, output) + for (name in listOf( + "WriteStaticHtml", + "preparedHtmlAttributes", + "HtmlStringWriterContext.writeText", + "AttrsScope.style" + )) { + assertTrue(name in output, output) + } + assertEquals(1, Regex("Missing or ambiguous runtime declarations:").findAll(output).count(), output) + } + + @Test + fun incompatibleRuntimeNamesTheMissingAttributeHelper() { + val (exit, output) = compile( + """ + package org.jetbrains.compose.web.dom + fun WriteStaticHtml(rootTag: String, encoded: String) {} + """.trimIndent(), + enabled = true, + ) + assertEquals(ExitCode.COMPILATION_ERROR, exit, output) + assertTrue("org.jetbrains.compose.web.dom.preparedHtmlAttributes" in output, output) + } + + private fun compile(source: String, enabled: Boolean? = null): Pair { + val directory = Files.createTempDirectory("html-runtime-diagnostic").toFile() + try { + val input = directory.resolve("Sample.kt").apply { writeText(source) } + return compileJvm( + listOf(input), directory.resolve("classes"), + java.io.File(Unit::class.java.protectionDomain.codeSource.location.toURI()).path, + lowering = enabled, + ) + } finally { + directory.deleteRecursively() + } + } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/StaticLoweringTest.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/StaticLoweringTest.kt new file mode 100644 index 0000000000..c9ac785076 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/StaticLoweringTest.kt @@ -0,0 +1,247 @@ +/* + * 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.assertNotEquals +import kotlin.test.assertTrue +import kotlinx.browser.dom.HTMLSpanElement +import org.jetbrains.compose.html.compiler.samples.EquivalentFragment +import org.jetbrains.compose.html.compiler.samples.EquivalentLiteral +import org.jetbrains.compose.html.compiler.samples.FragmentWithoutBoundary +import org.jetbrains.compose.html.compiler.samples.OrderedAttributes +import org.jetbrains.compose.html.compiler.samples.PreparedLiteralText +import org.jetbrains.compose.html.compiler.samples.ReorderedAttributes +import org.jetbrains.compose.html.compiler.samples.SharedContainer +import org.jetbrains.compose.html.compiler.samples.SharedDynamic +import org.jetbrains.compose.html.compiler.samples.SharedFragment +import org.jetbrains.compose.html.compiler.samples.SharedLiteral +import org.jetbrains.compose.html.compiler.samples.constantRuntimeCalls +import org.jetbrains.compose.html.compiler.samples.renderAfterConstantDepthLimit +import org.jetbrains.compose.html.compiler.samples.renderConstantExpressions +import org.jetbrains.compose.html.compiler.samples.renderCrossFileConstants +import org.jetbrains.compose.html.compiler.samples.renderEffectfulAlias +import org.jetbrains.compose.html.compiler.samples.renderMutableAlias +import org.jetbrains.compose.html.compiler.samples.renderOrdinaryVal +import org.jetbrains.compose.html.compiler.samples.renderPrimitiveExpressions +import org.jetbrains.compose.html.compiler.samples.renderRuntimeGetters +import org.jetbrains.compose.html.compiler.samples.renderShadowedConstant +import org.jetbrains.compose.html.compiler.samples.renderShortCircuitConstants +import org.jetbrains.compose.html.compiler.samples.renderThrowingExpression +import org.jetbrains.compose.html.compiler.samples.renderUserToString +import org.jetbrains.compose.html.compiler.samples.sharedInitialization +import org.jetbrains.compose.web.attributes.AttrsScope +import org.jetbrains.compose.web.attributes.AttrsScopeBuilder +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import org.jetbrains.org.objectweb.asm.ClassReader +import org.jetbrains.org.objectweb.asm.ClassVisitor +import org.jetbrains.org.objectweb.asm.MethodVisitor +import org.jetbrains.org.objectweb.asm.Opcodes + +class StaticLoweringTest { + @Test + fun literalsPreserveEscapingOrderingAndExplicitClasses() { + assertEquals( + "text", + composeHtmlToString { + Span(attrs = { + classes("label", "accent") + id("greeting") + attr("title", "\"<&>\r\n\t😀") + classes("last") + attr("data-key", "value") + }) { Text("text") } + } + ) + assertEquals("text", composeHtmlToString { + Span(attrs = { classes("ignored"); attr("class", "explicit") }) { Text("text") } + }) + } + + @Test + fun unsupportedPatternsKeepRuntimeValidation() { + val duplicate: @Composable () -> Unit = { + Span(attrs = { id("first"); id("last"); classes("label", "label") }) { Text("text") } + } + val invalidClass: @Composable () -> Unit = { Span(attrs = { classes("two words") }) { Text("text") } } + assertEquals("text", composeHtmlToString(content = duplicate)) + assertEquals("text", composeHtmlToString(content = invalidClass)) + assertEquals("text", composeHtmlToString { + Span(attrs = { classes(*arrayOf("first", "last")) }) { Text("text") } + }) + val invalid = listOf<@Composable () -> Unit>( + duplicate, + invalidClass, + { Span(attrs = { attr("title", "bad\u0000") }) { Text("text") } }, + { Span(attrs = { attr("data-compose-hydration-root", "bad") }) { Text("text") } }, + ) + for (page in invalid) { + assertFailsWith { composeHtmlToString(validateStrictly = true, content = page) } + } + } + + @Test + fun callsOnAnotherAttributeReceiverKeepTheirEffects() { + var assignedId: String? = null + val target = object : AttrsScopeBuilder() { + override fun attr(attr: String, value: String): AttrsScope { + assertEquals("id", attr) + assignedId = value + return this + } + } + assertEquals("text", composeHtmlToString { + Span(attrs = { target.id("changed") }) { Text("text") } + }) + assertEquals("changed", assignedId) + } + + @Test + fun constantsAliasesAndBuiltinExpressionsArePrecomputed() { + val cases = mapOf( + "
My <application> & friends
" to ::renderCrossFileConstants, + "
value=13
" to ::renderConstantExpressions, + "-3:1:8" to ::renderPrimitiveExpressions, + "false:true" to ::renderShortCircuitConstants, + ) + for ((expected, render) in cases) { + assertEquals(expected, render()) + } + assertEquals( + cases.keys + setOf("depth"), + staticHtmlConstants("ConstantExpressionsKt"), + ) + } + + @Test + fun depthLimitDoesNotPoisonLaterAliasEvaluation() { + assertEquals("depthdepth", renderAfterConstantDepthLimit()) + } + + @Test + fun gettersMutableAliasesAndEffectsStayAtRuntime() { + repeat(2) { + val before = constantRuntimeCalls + assertEquals("getter-${before + 2}", renderRuntimeGetters()) + assertEquals(before + 2, constantRuntimeCalls) + assertEquals("effect-${before + 3}", renderEffectfulAlias()) + assertEquals(before + 3, constantRuntimeCalls) + } + assertEquals("ordinary", renderOrdinaryVal()) + assertEquals("shadow", renderShadowedConstant("shadow")) + assertEquals("changed", renderMutableAlias()) + assertEquals("user", renderUserToString()) + assertFailsWith { renderThrowingExpression() } + } + + @Test + fun staticHtmlUsesConstantsAndDirectElementsNeedNoAttributeFields() { + val staticMethods = listOf( + "SharedLiteral", "EquivalentLiteral", "DistinctLiteral", + "SharedFragment", "EquivalentFragment", "FragmentWithoutBoundary" + ) + val methods = staticMethods + listOf( + "SharedDynamic", "SharedContainer", "SharedAttributesOtherTag", + "SameTagOtherType", "OrderedAttributes", "ReorderedAttributes", "FirstClassMetadata", "SecondClassMetadata" + ) + val reads = methods.associateWith { fieldReads("StaticSharingKt", it) } + assertTrue(reads.values.all { it.isEmpty() }) + + assertEquals( + staticHtmlConstants("StaticSharingKt", "SharedLiteral"), + staticHtmlConstants("StaticSharingKt", "EquivalentLiteral") + ) + assertNotEquals( + staticHtmlConstants("StaticSharingKt", "SharedLiteral"), + staticHtmlConstants("StaticSharingKt", "DistinctLiteral") + ) + assertEquals( + staticHtmlConstants("StaticSharingKt", "SharedFragment"), + staticHtmlConstants("StaticSharingKt", "EquivalentFragment") + ) + assertNotEquals( + staticHtmlConstants("StaticSharingKt", "SharedFragment"), + staticHtmlConstants("StaticSharingKt", "FragmentWithoutBoundary") + ) + + assertTrue(fieldReads("PreparedElementsKt", "PreparedSharedAttributes").isEmpty()) + val fields = Class.forName("${javaClass.packageName}.samples.StaticSharingKt").declaredFields + assertEquals(0, fields.count { it.name.startsWith("__htmlAttributes") }) + } + + @Test + fun sharedValuesPreserveInitializationRenderingAndEffects() { + val literal = "<& 😀" + assertEquals(literal.repeat(2), sharedInitialization) + forEachRenderMode { strict, hydratable, stream -> + var calls = 0 + repeat(2) { + val content: @Composable () -> Unit = { + SharedLiteral() + EquivalentLiteral() + PreparedLiteralText() + SharedDynamic { "value-${++calls}" } + SharedContainer("body") + SharedFragment() + EquivalentFragment() + FragmentWithoutBoundary() + } + val boundary = if (hydratable) "" else "" + val fragmentBoundary = "" + val fragment = "
<& 😀tail${fragmentBoundary}end
" + val expected = literal.repeat(3) + "value-${it + 1}" + + "body${boundary}tail" + fragment.repeat(2) + + "
<& 😀tailend
" + val actual = render(strict, hydratable, stream, content) + assertEquals(expected, actual) + assertEquals(it + 1, calls) + } + } + assertEquals( + "texttext", + composeHtmlToString { + OrderedAttributes("text") + ReorderedAttributes("text") + }) + } + + @Test + fun staticTreesCompileToInlinedBuilderAppends() { + var appends = 0 + var bridgeCalls = 0 + sampleBytecode("StaticSharingKt").accept(object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array? + ): MethodVisitor = + object : MethodVisitor(Opcodes.ASM9) { + override fun visitMethodInsn( + opcode: Int, + owner: String, + method: String, + descriptor: String, + isInterface: Boolean + ) { + if (method == "WriteStaticHtml") bridgeCalls++ + if (name == "SharedFragment" && owner == "java/lang/StringBuilder" && method == "append" && + descriptor == "(Ljava/lang/String;)Ljava/lang/StringBuilder;" + ) appends++ + } + } + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + assertEquals(1, appends) + assertEquals(0, bridgeCalls, "The writer bridge should be inlined into the generated code") + } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/TestSupport.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/TestSupport.kt new file mode 100644 index 0000000000..78c7101bd3 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/TestSupport.kt @@ -0,0 +1,333 @@ +/* + * 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.compiler.plugins.kotlin.ComposePluginRegistrar +import androidx.compose.runtime.Composable +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.PrintStream +import java.net.URLClassLoader +import java.nio.file.Files +import kotlin.test.assertEquals +import org.jetbrains.compose.web.composeHtmlToStream +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.kotlin.cli.common.ExitCode +import org.jetbrains.kotlin.cli.jvm.K2JVMCompiler +import org.jetbrains.org.objectweb.asm.ClassReader +import org.jetbrains.org.objectweb.asm.ClassVisitor +import org.jetbrains.org.objectweb.asm.MethodVisitor +import org.jetbrains.org.objectweb.asm.Opcodes + +internal fun staticHtmlConstants(file: String, method: String? = null): Set { + val constants = mutableSetOf() + // Compose moves stored content lambdas into a separate class. + val files = if (method == null) listOf(file, "ComposableSingletons\$$file") else listOf(file) + for (className in files) { + sampleBytecode(className).accept(object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array? + ): MethodVisitor? { + if (method != null && method != name) return null + return object : MethodVisitor(Opcodes.ASM9) { + private val strings = mutableMapOf() + private var string: String? = null + private var encoded: String? = null + + override fun visitLdcInsn(value: Any) { + string = value as? String + if (string?.startsWith('<') == true) encoded = string + } + + override fun visitVarInsn(opcode: Int, variable: Int) { + when (opcode) { + Opcodes.ASTORE -> { + strings.remove(variable) + string?.let { strings[variable] = it } + } + + Opcodes.ALOAD -> { + string = strings[variable] + if (string?.startsWith('<') == true) encoded = string + } + } + } + + override fun visitMethodInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean + ) { + if (name == "beginStaticHtml") encoded?.let(constants::add) + string = null + } + } + } + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + } + return constants +} + +private object TestResources + +internal fun sampleBytecode(file: String): ClassReader { + val path = "/${TestResources::class.java.packageName.replace('.', '/')}/" + val stream = TestResources::class.java.getResourceAsStream("${path}samples/$file.class") + ?: TestResources::class.java.getResourceAsStream("$path$file.class") + return requireNotNull(stream).use { ClassReader(it.readBytes()) } +} + +internal fun visitMethods(reader: ClassReader, method: String? = null, visitor: (String) -> MethodVisitor) { + reader.accept(object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, name: String, descriptor: String, signature: String?, exceptions: Array?, + ): MethodVisitor? = if (method == null || method == name) visitor(name) else null + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) +} + +internal fun visitSampleMethod(file: String, method: String, visitor: MethodVisitor) = + visitMethods(sampleBytecode(file), method) { visitor } + +internal fun classMethodCalls(reader: ClassReader, ownerPrefix: String? = null): Map> { + val calls = linkedMapOf>() + visitMethods(reader) { method -> + val targets = calls.getOrPut(method) { mutableListOf() } + object : MethodVisitor(Opcodes.ASM9) { + override fun visitMethodInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean + ) { + if (ownerPrefix == null || owner.startsWith(ownerPrefix)) targets.add(name) + } + } + } + return calls +} + +internal fun methodCalls(file: String, method: String, ownerPrefix: String? = null): List = + classMethodCalls(sampleBytecode(file), ownerPrefix)[method].orEmpty() + +internal fun methodStrings(reader: ClassReader, method: String? = null): Set { + val strings = mutableSetOf() + visitMethods(reader, method) { + object : MethodVisitor(Opcodes.ASM9) { + override fun visitLdcInsn(value: Any) { + if (value is String) strings.add(value) + } + } + } + return strings +} + +internal fun methodStrings(file: String, method: String): Set = methodStrings(sampleBytecode(file), method) + +internal fun fieldReads(file: String, method: String): Set> { + val reads = mutableSetOf>() + visitSampleMethod(file, method, object : MethodVisitor(Opcodes.ASM9) { + override fun visitFieldInsn(opcode: Int, owner: String, name: String, descriptor: String) { + if (opcode == Opcodes.GETSTATIC && name.startsWith("__html")) reads.add(owner to name) + } + }) + return reads +} + +internal inline fun forEachRenderMode(test: (strict: Boolean, hydratable: Boolean, stream: Boolean) -> Unit) { + val variants = listOf(false, true) + for (strict in variants) { + for (hydratable in variants) { + for (stream in variants) { + test(strict, hydratable, stream) + } + } + } +} + +internal fun render(strict: Boolean, hydratable: Boolean, stream: Boolean, content: @Composable () -> Unit): String { + if (!stream) return composeHtmlToString(validateStrictly = strict, hydratable = hydratable, content = content) + val chunks = StringBuilder() + composeHtmlToStream( + validateStrictly = strict, hydratable = hydratable, chunkSize = 1, + sink = { chunks.append(it) }, content = content + ) + return chunks.toString() +} + +/** Runs the same JVM compiler setup for rendering comparisons and runtime-contract diagnostics. */ +internal fun compileJvm( + sources: List, + output: File, + classpath: String, + lowering: Boolean?, + compose: Boolean = false, +): Pair { + val arguments = mutableListOf( + "-no-stdlib", "-no-reflect", "-jvm-target", "11", "-Xverify-ir=error", + "-classpath", classpath, + "-d", output.path, + "-Xplugin=${System.getProperty("htmlLowering.pluginClasspath")}", + ) + if (compose) { + val composePlugin = File(ComposePluginRegistrar::class.java.protectionDomain.codeSource.location.toURI()) + arguments.addAll( + listOf( + "-Xplugin=${composePlugin.path}", + "-Xcompiler-plugin-order=org.jetbrains.compose.html.lowering>androidx.compose.compiler.plugins.kotlin", + ) + ) + } + if (lowering != null) arguments.addAll(listOf("-P", "plugin:org.jetbrains.compose.html.lowering:enabled=$lowering")) + arguments.addAll(sources.map { it.path }) + val log = ByteArrayOutputStream() + val exit = PrintStream(log).use { K2JVMCompiler().exec(it, *arguments.toTypedArray()) } + return exit to log.toString(Charsets.UTF_8) +} + +/** Compiles a suite once in each mode and retains its outputs after deleting the temporary files. */ +internal class LoweringComparison( + private val cases: Map, + private val matrixCases: Set = cases.keys, + declarations: String = "", + extraSources: Map = emptyMap(), +) { + val ordinary: Map + private val actual: Map + private val classes: Map + val bytecode: ClassReader get() = bytecode("sample.SampleKt") + fun bytecode(className: String): ClassReader = ClassReader(classes.getValue(className)) + + init { + val directory = Files.createTempDirectory("html-lowering-comparison").toFile() + try { + val source = directory.resolve("Sample.kt").apply { writeText(source(declarations)) } + val sources = listOf(source) + extraSources.map { (name, body) -> + directory.resolve(name).apply { writeText(body) } + } + + fun compile(lowering: Boolean): File { + val output = directory.resolve(if (lowering) "lowered" else "ordinary") + val (exit, log) = compileJvm( + sources, + output, + System.getProperty("java.class.path"), + lowering, + compose = true + ) + assertEquals(ExitCode.OK, exit, log) + return output + } + ordinary = render(compile(false)) + val lowered = compile(true) + actual = render(lowered) + classes = lowered.walkTopDown().filter { it.isFile && it.extension == "class" }.associate { + it.relativeTo(lowered).path.removeSuffix(".class").replace(File.separatorChar, '.') to it.readBytes() + } + } finally { + directory.deleteRecursively() + } + } + + val writerCalls: Map> by lazy { + classMethodCalls(bytecode, "org/jetbrains/compose/web/dom/HtmlStringWriterContext").mapValues { (_, calls) -> + calls.filter { it != "flushChunk\$default" }.groupingBy { it }.eachCount() + } + } + + fun assertEquivalent(names: Collection = cases.keys) { + for (name in names) { + assertEquals(ordinary.getValue(name), actual.getValue(name), "$name: ${cases.getValue(name).take(200)}") + } + } + + private fun source(declarations: String): String = buildString { + appendLine( + """ + @file:OptIn(org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi::class, + org.jetbrains.compose.web.ExperimentalComposeWebSvgApi::class) + @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package sample + import androidx.compose.runtime.Composable + import kotlinx.browser.dom.Element + import org.jetbrains.compose.web.composeHtmlToStream + import org.jetbrains.compose.web.composeHtmlToString + import org.jetbrains.compose.web.dom.* + import org.jetbrains.compose.web.svg.* + import org.jetbrains.compose.web.attributes.* + import org.jetbrains.compose.web.css.* + + private val events = mutableListOf() + fun Mark(value: String): String { events.add(value); return value } + // Inspect test-only state without publishing runtime accessors for the IR verifier. + private fun writerField(writer: Any, name: String): Any? = + writer.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(writer) + @Composable fun Probe() { + val writer = currentHtmlStringWriter() + Text("[${'$'}{writerField(writer, "currentTagName")}|${'$'}{writerField(writer, "currentNamespace")}|${'$'}{writerField(writer, "previousSiblingWasText")}]") + } + """.trimIndent() + ) + appendLine(declarations) + for ((name, body) in cases) { + appendLine("@Composable fun $name(name: String) {") + appendLine(body.prependIndent(" ")) + appendLine("}") + } + appendLine( + """ + fun render(case: String, strict: Boolean, hydratable: Boolean, stream: Boolean): String { + events.clear() + val observed = StringBuilder() + val content: @Composable () -> Unit = { sample(case, "dynamic<&é😀") } + val result = try { + if (!stream) composeHtmlToString(validateStrictly = strict, hydratable = hydratable, content = content) + else { + composeHtmlToStream(validateStrictly = strict, hydratable = hydratable, chunkSize = 1, + sink = { observed.append(it) }, content = content) + observed.toString() + } + } catch (t: Throwable) { + t.javaClass.name + ":" + t.message + "|" + observed + } + return result + "|events=" + events + } + """.trimIndent() + ) + appendLine("@Composable private fun sample(case: String, name: String) {") + appendLine(" when (case) {") + for (name in cases.keys) { + appendLine(" \"$name\" -> $name(name)") + } + appendLine(" }") + appendLine("}") + } + + private fun render(output: File): Map = + URLClassLoader(arrayOf(output.toURI().toURL()), javaClass.classLoader).use { loader -> + val method = loader.loadClass("sample.SampleKt").getMethod( + "render", String::class.java, Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, Boolean::class.javaPrimitiveType, + ) + cases.keys.associateWith { name -> + buildList { + if (name !in matrixCases) add(method.invoke(null, name, true, false, false).toString()) + else forEachRenderMode { strict, hydratable, stream -> + add( + "strict=$strict hydratable=$hydratable stream=$stream: " + + method.invoke(null, name, strict, hydratable, stream) + ) + } + }.joinToString("\n") + } + } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/WrapperLoweringTest.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/WrapperLoweringTest.kt new file mode 100644 index 0000000000..1b836d334c --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/WrapperLoweringTest.kt @@ -0,0 +1,423 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WrapperLoweringTest { + @Test + fun wrapperArgumentsAndSpecializedScopesPreserveRuntimeBehavior() { + comparison.assertEquivalent(behaviorCases.keys) + } + + @Test + fun wrappersSelectDirectWritersAndRejectUnsupportedOverloads() { + val calls = comparison.writerCalls + for (name in listOf( + "WrapperParameters", "FormActions", "TypedValues", "ValuePrecedence", + "CheckedPrecedence", "TextAreaValues", "ListenerEffects", "DirectElements", "ArgumentOrder", + "BoundListener", "CustomInputType", "DynamicInput", "SvgStyles", "TextAreaStyles", + )) assertTrue((calls.getValue(name)["finishMixedElement"] ?: 0) > 0, "Expected mixed lowering: $name") + assertEquals(2, calls.getValue("SvgOverride")["finishMixedElement"]) + for (name in listOf("UnmarkedInputOverloads", "SvgDuplicate", "SvgDuplicateBranches")) { + assertEquals(emptyMap(), calls.getValue(name), name) + } + assertTrue( + methodStrings( + comparison.bytecode, + "LiteralListener" + ).any { "" in it }) + } + + @Test + fun metadataNamesAndConversionsUseDirectWriters() { + comparison.assertEquivalent(metadataCases.keys) + for (name in metadataCases.keys) { + assertTrue( + (comparison.writerCalls.getValue(name)["finishMixedElement"] ?: 0) > 0, + "Expected annotated attributes to lower directly: $name" + ) + } + } + + @Test + fun omittedDefaultsMatchExplicitArguments() { + comparison.assertEquivalent(defaultCases.keys) + for (name in defaults.keys) { + assertEquals( + comparison.ordinary.getValue("Omitted$name"), comparison.ordinary.getValue("Explicit$name"), + "Marker default drift: $name" + ) + } + } + + companion object { + // One example per metadata behavior; form control and SVG interactions have their own tests. + private val metadataCases = mapOf( + "InferredNamesAndPrimitiveValues" to """ + Div(attrs = { title(Mark(name)); tabIndex(3); contentEditable(true) }) + """.trimIndent(), + "ExplicitHtmlName" to "Form(attrs = { acceptCharset(Mark(name)) })", + "EnumPropertyConversion" to "Div(attrs = { dir(DirType.Rtl); inputMode(InputMode.Text); title(Mark(name)) })", + "EnumToStringConversion" to "Input(InputType.Text) { autoComplete(AutoComplete.off); title(Mark(name)) }", + "BooleanOnOffConversion" to "Form(attrs = { autoComplete(false); title(Mark(name)) })", + "ConstantAndEmptyValues" to "Button(attrs = { disabled(); hidden(); title(Mark(name)) })", + "InheritedMember" to "Input(InputType.Text) { lang(Mark(name)) }", + "SvgAttributeNames" to "Svg { G(attrs = { viewBox(Mark(name)); fillRule(\"evenodd\") }) }", + "SvgNumberFormatting" to "Svg { G(attrs = { width(3); fillOpacity(1.25); id(Mark(name)) }) }", + "SvgCssValueConversion" to "Svg { G(attrs = { width(3.px); fillOpacity(25.percent); id(Mark(name)) }) }", + ) + + // Representative enum, string, boolean, nullable and numeric defaults. + // Other wrappers exercise these same forms in the behavior cases. + private val defaults = mapOf( + "HelperTarget" to ("A(attrs = { target() })" to "A(attrs = { target(ATarget.Self) })"), + "HelperDownload" to ("A(attrs = { download() })" to "A(attrs = { download(\"\") })"), + "HelperAutoComplete" to ("Form(attrs = { autoComplete() })" to "Form(attrs = { autoComplete(true) })"), + "Range" to ("RangeInput()" to "RangeInput(null, null, null, 1)"), + "Img" to ("Img(\"src\")" to "Img(\"src\", \"\")"), + "SvgText" to ("Svg { SvgText(\"text\") }" to "Svg { SvgText(\"text\", 0, 0) }"), + ) + private val defaultCases = buildMap { + for ((name, pair) in defaults) { + put("Omitted$name", pair.first) + put("Explicit$name", pair.second) + } + } + + private val behaviorCases = linkedMapOf( + "LiteralListener" to "Button(attrs = { classes(\"buy\"); onClick { Mark(\"unused\") } }) { Text(\"Buy\") }", + "UncoveredParameters" to "Uncovered(); Uncovered(mode = null); WrongReceiver(); WrongReceiver(href = null)", + "CustomInputType" to """ + Input(InputType.InputTypeWithStringValue("hidden")) { + value(Mark("controlled")) + attr("data-mid", "m") + defaultValue("default") + } + """.trimIndent(), + "BoundListener" to "Button(attrs = { onClick(clickTarget()::click); attr(\"data-x\", Mark(name)) }) { Text(name) }", + "WrapperParameters" to """ + A(href = Mark(name), attrs = { + attr("data-first", Mark("a")) + attr("href", "override") + }) { + Text(name) + } + Img(src = Mark(name)) + Label(forId = Mark(name)) { + Text(name) + } + OptGroup(label = Mark(name)) { + Option(Mark(name), attrs = { + selected() + }) { + Text(name) + } + } + """.trimIndent(), + "FormActions" to """ + for (action in listOf(null, "", name)) Form(action = action, attrs = { + attr("data-x", Mark(name)) + attr("action", Mark("override")) + }) { + Text(name) + } + Form() + Form(action = "") + """.trimIndent(), + "TypedValues" to """ + RadioInput(true) { attr("data-x", Mark(name)) } + NumberInput(1.25, 0, 10) { value(3); defaultValue(7) } + RangeInput(2, 0, 10, 0.5) { step(2); min("-1") } + """.trimIndent(), + "ValuePrecedence" to """ + Input(InputType.Text) { + value(Mark("first")) + attr("data-mid", "m") + defaultValue("default") + attr("data-last", "z") + value(Mark("last")) + } + Input(InputType.Text) { + attr("value", "default") + attr("data-last", "z") + value("controlled") + } + """.trimIndent(), + "CheckedPrecedence" to """ + CheckboxInput { + defaultChecked() + attr("data-x", "x") + } + Input(InputType.Checkbox) { + checked(true) + attr("data-a", "a") + defaultChecked() + checked(false) + } + Input(InputType.Checkbox) { + checked(false) + attr("checked", "yes") + checked(true) + } + """.trimIndent(), + "DynamicInput" to """ + for (type in listOf(InputType.Text, InputType.Hidden, InputType.Submit, InputType.Checkbox, InputType.Radio)) { + Input(type) { + value(Mark("controlled")) + attr("data-mid", "m") + defaultValue(Mark("default")) + onInput { + } + } + Input(type) + } + Input(InputType.Text) + """.trimIndent(), + "TextAreaValues" to """ + for (value in listOf(null, "", "\n<&", name)) { + TextArea(value = value, attrs = { + defaultValue(Mark("default")) + value(Mark("inner")) + defaultValue("last default") + rows(3) + onInput { + } + }) + TextArea(value = value) + } + TextArea { + defaultValue("\n<&") + cols(2) + } + TextArea { + value("") + defaultValue("ignored") + } + """.trimIndent(), + "ListenerEffects" to """ + TextInput { + onInput(listener = kotlin.run { Mark("listener"); { _ -> Unit } }) + ref { Mark("ref should not run"); onDispose {} } + attr("data-x", Mark(name)) + } + Button(attrs = { + onClick(listener = kotlin.run { Mark("click listener"); { _ -> Unit } }) + }) { Text(name) } + """.trimIndent(), + "DirectElements" to """ + TagElement(tagName = "time", applyAttrs = { + attr("datetime", Mark(name)) + }, content = { + Text(name) + }) + TagElementNS("circle", "http://www.w3.org/2000/svg", { + attr("cx", Mark(name)) + }, null) + TagElement(ElementBuilder.createBuilder("article"), null) { + Text(name) + } + """.trimIndent(), + "ArgumentOrder" to """ + Img(alt = Mark("alt"), src = Mark("src"), attrs = { + attr("data-x", Mark(name)) + }) + Select(multiple = run { + Mark("multiple") + true + }, attrs = { + attr("data-x", Mark("attrs")) + }) + TextArea(attrs = { + defaultValue(Mark("attrs")) + }, value = Mark("value")) + """.trimIndent(), + "UnsupportedWrappers" to """ + val attrs: org.jetbrains.compose.web.attributes.builders.InputAttrsScope.() -> Unit = { + value(Mark(name)) + } + TextInput(attrs = attrs) + TextArea(attrs = { + customTextAreaHelper() + }) + Img(Mark("src"), attrs = { + customImageHelper() + }) + """.trimIndent(), + "SvgNullableViewBox" to """ + Svg(viewBox = if (name.isEmpty()) null else Mark("0 0 50 50"), attrs = { + attr("preserveAspectRatio", "xMidYMid") + attr("hidden", "") + }) { + Circle(1, 2, 3) + Probe() + } + Svg(viewBox = null) { + Circle(4, 5, 6) + Probe() + } + Svg { + Probe() + } + """.trimIndent(), + "SvgOverride" to """ + Svg { + Circle(cx = 1, cy = 2, r = 3, attrs = { + attr("data-first", Mark(name)) + attr("cx", "2") + attr("data-last", "last") + }) + Circle(cx = MarkNumber(1.25), cy = MarkNumber(2), r = MarkNumber(3), attrs = { + attr("hidden", Mark("")) + attr("cx", Mark("2")) + }) { + Probe() + } + } + """.trimIndent(), + "SvgDuplicate" to "Svg(viewBox = \"0 0 50 50\", attrs = { attr(\"viewbox\", Mark(name)) }) { Probe() }", + "SvgDuplicateBranches" to """ + Svg(attrs = { + if (name.isNotEmpty()) attr("viewBox", Mark("first")) + attr("viewbox", Mark(name)) + }) { + Probe() + } + """.trimIndent(), + "SvgCallbackFallback" to """ + Svg { + val callback: AttrBuilderContext = { + attr("cx", Mark(name)) + } + Circle(1, 2, 3, attrs = callback) + SvgElement("foreignObject") { + Div { + Text(name) + Probe() + } + } + Probe() + } + """.trimIndent(), + "SvgTextAndCamelCase" to """ + Svg { + Path(Mark("M 0 0 L 5 5")) + SvgText(Mark(name), MarkNumber(1.25), MarkNumber(2)) + Title("&") + AnimateMotion() + TextPath("#path", name) + Probe() + } + """.trimIndent(), + "SvgArgumentOrder" to """ + Svg { + Circle(r = MarkNumber(3), cx = MarkNumber(1), cy = MarkNumber(2), attrs = { + attr("data-value", Mark(name)) + }) + SvgText(y = MarkNumber(4), text = Mark(name), x = MarkNumber(5)) + Scope(this).Circle(1, 2, 3) + Probe() + } + """.trimIndent(), + "SvgStyles" to """ + Svg(attrs = { + style { + property("color", Mark(name)) + } + }) { + Circle(1, 2, 3, attrs = { + style { + property("fill", Mark("red")) + } + }) + Probe() + } + """.trimIndent(), + "TextAreaStyles" to """ + TextArea(value = Mark("\n<&"), attrs = { + defaultValue(Mark("default")) + style { + property("color", Mark(name)) + } + if (name.isNotEmpty()) style { + property("display", Mark("none")) + } + }) + """.trimIndent(), + "MixedLet" to "Div(attrs = { name.let { value -> id(Mark(value)); attr(\"title\", Mark(value)) } }) { Text(name) }", + "StyleException" to """ + Div(attrs = { + attr("title", Mark("before")) + style { + property("color", Mark(name)) + error("style failure") + } + attr("data-after", Mark("after")) + }) + """.trimIndent(), + "LocalValueReturn" to """ + Div(attrs = { + attr("title", run { + fun title(): String { + return Mark(name) + } + title() + }) + }) { + Probe() + } + """.trimIndent(), + "UnmarkedInputOverloads" to """ + org.jetbrains.compose.web.dom.Input(type = Mark(name), attrs = { Text("custom input") }) + org.jetbrains.compose.web.dom.Input(type = InputType.Text, attrs = null as String?) + """.trimIndent(), + ) + private val comparison by lazy { + LoweringComparison( + metadataCases + defaultCases + behaviorCases, + matrixCases = setOf( + "SvgDuplicate", + "SvgDuplicateBranches", + "StyleException", + "TextAreaValues", + "SvgCallbackFallback" + ), + declarations = """ + @Composable @LowerableHtmlTag("div") + fun Uncovered(mode: String? = null, attrs: AttrBuilderContext<kotlinx.browser.dom.HTMLDivElement>? = null) { + Div(attrs) { Text("kept") } + } + @Composable @LowerableHtmlTag("input") + fun WrongReceiver(href: String? = null, attrs: AttrBuilderContext<kotlinx.browser.dom.HTMLInputElement>? = null) { + Input(InputType.Text) { attrs?.invoke(this) } + } + class ClickTarget { fun click(event: androidx.compose.web.events.SyntheticMouseEvent) {} } + fun clickTarget(): ClickTarget { Mark("bound receiver"); return ClickTarget() } + fun org.jetbrains.compose.web.attributes.builders.TextAreaAttrsScope.customTextAreaHelper() { + value(Mark("custom")) + } + fun org.jetbrains.compose.web.attributes.AttrsScope<kotlinx.browser.dom.HTMLImageElement>.customImageHelper() { + attr("src", Mark("custom")) + } + fun MarkNumber(value: Number): Number { Mark("number=${'$'}value"); return value } + fun Scope(scope: ElementScope<kotlinx.browser.dom.svg.SVGElement>): ElementScope<kotlinx.browser.dom.svg.SVGElement> { + Mark("scope") + return scope + } + """.trimIndent(), extraSources = mapOf( + "ExtraInput.kt" to """ + package org.jetbrains.compose.web.dom + import androidx.compose.runtime.Composable + @Composable fun Input(type: String, attrs: @Composable () -> Unit) { Text(type); attrs() } + @Composable fun Input(type: org.jetbrains.compose.web.attributes.InputType<*>, attrs: String?) { Text("custom typed input") } + """.trimIndent() + ) + ) + } + } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/ConstantExpressions.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/ConstantExpressions.kt new file mode 100644 index 0000000000..a14cfb2072 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/ConstantExpressions.kt @@ -0,0 +1,131 @@ +/* + * 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 org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import org.jetbrains.compose.html.compiler.samples.APP_NAME as applicationName +import org.jetbrains.compose.html.compiler.samples.CARD_CLASS +import org.jetbrains.compose.html.compiler.samples.COUNT +import org.jetbrains.compose.html.compiler.samples.Tokens.SUFFIX + +private const val SAME_FILE = "same" + +fun renderCrossFileConstants() = composeHtmlToString { + Div(attrs = { + classes(CARD_CLASS) + id(org.jetbrains.compose.html.compiler.samples.Theme.ID) + attr(org.jetbrains.compose.html.compiler.samples.ATTRIBUTE, SAME_FILE + SUFFIX) + }) { + Span { Text(applicationName) } + } +} + +fun renderConstantExpressions() = composeHtmlToString { + val count = COUNT + val twice = count * 2 + val title = "items=$twice" + Div(attrs = { + val name = "ti" + "tle" + attr(name, title) + }) { + val label = "value=" + (twice + 1) + Span { Text(label) } + } +} + +fun renderPrimitiveExpressions() = composeHtmlToString { + val max = Int.MAX_VALUE + val yes = COUNT > 2 && COUNT != 0 + val long = 40L + 2L + val character = 'A' + val nothing: String? = null + Span(attrs = { attr("title", "${max + 1}:$yes:$long:$character:$nothing") }) { + Text("${-COUNT / 2}:${7 % COUNT}:${1 shl 3}") + } +} + +private fun unreachableEffect(): Boolean = error("Unreachable") + +fun renderShortCircuitConstants() = composeHtmlToString { + val no = false + val yes = true + val and = no && unreachableEffect() + val or = yes || unreachableEffect() + Span { Text("$and:$or") } +} + +var constantRuntimeCalls = 0 + +val runtimeLabel: String + get() = "getter-${++constantRuntimeCalls}" + +fun renderRuntimeGetters() = composeHtmlToString { + Span(attrs = { attr("title", runtimeLabel) }) { Text(runtimeLabel) } +} + +fun renderEffectfulAlias() = composeHtmlToString { + val label = "effect-${++constantRuntimeCalls}" + Span { Text(label) } +} + +fun renderMutableAlias() = composeHtmlToString { + var label = "first" + label = "changed" + Span { Text(label) } +} + +private object UserLabel { + override fun toString() = "user" +} + +fun renderUserToString() = composeHtmlToString { + Span { Text("$UserLabel") } +} + +fun renderThrowingExpression() = composeHtmlToString { + val zero = 0 + Span { Text("${1 / zero}") } +} + +private val ordinaryLabel = "ordinary" + +fun renderOrdinaryVal() = composeHtmlToString { + Span { Text(ordinaryLabel) } +} + +fun renderShadowedConstant(CARD_CLASS: String) = composeHtmlToString { + Span(attrs = { classes(CARD_CLASS) }) { Text(CARD_CLASS) } +} + +fun renderAfterConstantDepthLimit() = composeHtmlToString { + val value = "depth" + // The 64 nested operations reach value just before the depth limit. + Span(attrs = { id("deep") }) { + Text( + ("" + ("" + ("" + ("" + ("" + ("" + ("" + ("" + + ("" + ("" + ("" + ("" + ("" + ("" + ("" + ("" + + ("" + ("" + ("" + ("" + ("" + ("" + ("" + ("" + + ("" + ("" + ("" + ("" + ("" + ("" + ("" + ("" + + ("" + ("" + ("" + ("" + ("" + ("" + ("" + ("" + + ("" + ("" + ("" + ("" + ("" + ("" + ("" + ("" + + ("" + ("" + ("" + ("" + ("" + ("" + ("" + ("" + + ("" + ("" + ("" + ("" + ("" + ("" + ("" + ("" + + value + )))))))) + )))))))) + )))))))) + )))))))) + )))))))) + )))))))) + )))))))) + )))))))) + ) + } + Span { Text(value) } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/CrossFileSupport.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/CrossFileSupport.kt new file mode 100644 index 0000000000..f5fb5cfa54 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/CrossFileSupport.kt @@ -0,0 +1,59 @@ +/* + * 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 kotlinx.browser.dom.HTMLDivElement +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div as HtmlDiv +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text + +val sharedContent: @Composable () -> Unit = { Span { Text("shared lambda") } } + +@Composable +fun SharedPage() { + Span { Text("shared function") } +} + +const val CARD_CLASS = "card" +const val APP_NAME = "My <application> & friends" +const val ATTRIBUTE = "title" +const val COUNT = 6 + +object Tokens { + const val SUFFIX = "!" +} + +class Theme { + companion object { + const val ID = "main" + } +} + +// A same-named application annotation must not be mistaken for the compiler contract. +@Target(AnnotationTarget.FUNCTION) +private annotation class LowerableHtmlTag + +var customTagCalls = 0 + private set + +@Composable +fun UserDefinedPage() { + Div(attrs = { id("custom") }) { Text("text") } +} + +// Same name and signature as the built-in wrapper, but with an observable effect. +@Composable +@LowerableHtmlTag +private fun Div( + attrs: AttrBuilderContext<HTMLDivElement>? = null, + content: ContentBuilder<HTMLDivElement>? = null, +) { + customTagCalls++ + HtmlDiv(attrs, content) +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/DirectElements.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/DirectElements.kt new file mode 100644 index 0000000000..a6da7f84b0 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/DirectElements.kt @@ -0,0 +1,78 @@ +/* + * 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 androidx.compose.runtime.remember +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Pre +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Table +import org.jetbrains.compose.web.dom.Text +import org.jetbrains.compose.web.dom.Title +import org.jetbrains.compose.web.dom.Tr + +@Composable +fun DirectGreeting(value: () -> String) { + Div(attrs = { + classes("card") + id("greeting") + attr("title", "Hello & goodbye") + }) { Text(value()) } +} + +@Composable +fun DirectContainer(values: List<String>, effect: (String) -> Unit) { + Div(attrs = { classes("container") }) { + Span { Text("head") } + effect("open") + val prefix = "value: " + for (value in values) { + if (value.isNotEmpty()) { + Span { Text(prefix + value) } + } else { + Text("empty") + } + } + DirectFooter(effect) + Span { Text("middle") } + Span { Text("sibling") } + effect("close") + Span { Text("tail") } + } +} + +@Composable +private fun DirectFooter(effect: (String) -> Unit) { + effect("footer") + Text("footer") +} + +@Composable +fun ComposableText(value: @Composable () -> String) { + Span(attrs = { classes("user") }) { Text(value()) } +} + +@Composable +fun RememberedText(value: String) { + Span { Text(remember(value) { value }) } +} + +@Composable +fun SpecialText(value: String) { + Pre { Text(value) } + Title { Text(value) } + Table { Tr { Text(value) } } +} + +@Composable +fun ContainerReturn(skip: Boolean, effect: () -> Unit) { + Div { + effect() + if (skip) return@Div + Text("shown") + } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/LargeAttribute.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/LargeAttribute.kt new file mode 100644 index 0000000000..e95c305711 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/LargeAttribute.kt @@ -0,0 +1,31 @@ +/* + * 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 kotlinx.browser.dom.HTMLSpanElement +import org.jetbrains.compose.web.dom.* + +// Folding produces a literal larger than the JVM constant pool entry limit. +private const val AttributeHundred = + "界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界" +private const val AttributeThousand = AttributeHundred + AttributeHundred + AttributeHundred + AttributeHundred + + AttributeHundred + AttributeHundred + AttributeHundred + AttributeHundred + AttributeHundred + AttributeHundred +private const val AttributeUnit = AttributeThousand + AttributeThousand + AttributeThousand + +@Composable +fun LargeDirectAttribute(value: String) { + val attribute = AttributeUnit + AttributeUnit + AttributeUnit + AttributeUnit + + AttributeUnit + AttributeUnit + AttributeUnit + AttributeUnit + Span(attrs = { attr("title", attribute) }) { Text(value) } +} + +@Composable +fun LargePreparedAttribute(content: ContentBuilder<HTMLSpanElement>?) { + val attribute = AttributeUnit + AttributeUnit + AttributeUnit + AttributeUnit + + AttributeUnit + AttributeUnit + AttributeUnit + AttributeUnit + Span(attrs = { attr("title", attribute) }, content = content) +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/LoopRendering.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/LoopRendering.kt new file mode 100644 index 0000000000..907e338019 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/LoopRendering.kt @@ -0,0 +1,139 @@ +/* + * 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:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package org.jetbrains.compose.html.compiler.samples + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import org.jetbrains.compose.web.HtmlValidationMode +import org.jetbrains.compose.web.LocalHtmlValidationMode +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import org.jetbrains.compose.web.dom.Table + +@Composable +fun LoopRows(values: List<String>) { + Div { + values.forEach { value -> + Div { + Span { Text(value) } + Span { Text(value) } + } + } + } +} + +@Composable +fun TopLevelLoop(values: List<String>) { + for (value in values) { + Span { Text(value) } + } +} + +@Composable +fun TopLevelForEach(values: List<String>) { + values.forEachIndexed { index, value -> + Span { Text("$index:$value") } + } +} + +@Composable +fun TopLevelLoopText(values: List<String>) { + values.forEach { Text(it) } +} + +@Composable +fun NestedLoops(values: List<String>, effect: (String) -> Unit) { + Div { + var index = 0 + while (index < values.size) { + val value = values[index++] + effect(value) + if (value.isEmpty()) continue + if (value == "stop") break + repeat(2) { + values.asSequence().forEachIndexed { position, item -> + if (position == 0) return@forEachIndexed + Span { Text(value + item) } + } + } + } + do { + Span { Text("done:$index") } + } while (false) + } +} + +@Composable +fun LoopCallbacks(values: List<String>, store: (@Composable () -> Unit) -> Unit) { + Div { + values.forEach { value -> + Span { Text(value) } + store { Span { Text("stored:$value") } } + CompositionLocalProvider(LocalHtmlValidationMode provides HtmlValidationMode.Strict) { + values.forEach { nested -> Span { Text(nested) } } + } + } + } +} + +@Composable +fun LoopValidationScope(values: List<String>) { + Table { + CompositionLocalProvider(LocalHtmlValidationMode provides HtmlValidationMode.Strict) { + values.forEach { value -> Div { Text(value) } } + } + } +} + +@Composable +fun ConstantRepeat() { + val count = 1 + 2 + Div { repeat(count) { Span { Text("<&") } } } +} + +@Composable +fun EmptyConstantRepeats(effect: () -> Unit) { + Div { + repeat(0) { effect() } + repeat(-2) { effect() } + } +} + +@Composable +fun IndexedRepeat() { + Div { repeat(3) { Span { Text("$it") } } } +} + +@Composable +fun EffectfulRepeat(effect: () -> Unit) { + Div { repeat(2) { effect() } } +} + +@Composable +fun LargeRepeat() { + Div { repeat(10_000) { Span { Text("<&") } } } +} + +@Composable +fun LoopTextBoundaries(values: List<String>) { + Div { values.forEach { Text(it) } } +} + +private class AuthoredRepeat { + @Composable + fun repeat(count: Int, effect: () -> Unit) { + effect() + Text("authored:$count") + } +} + +@Composable +fun ShadowedRepeat(effect: () -> Unit) { + Div { AuthoredRepeat().repeat(3, effect) } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/MixedAttributes.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/MixedAttributes.kt new file mode 100644 index 0000000000..8d00de3725 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/MixedAttributes.kt @@ -0,0 +1,195 @@ +/* + * 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.attributes.name +import org.jetbrains.compose.web.attributes.required +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.css.StyleScope +import org.jetbrains.compose.web.css.opacity +import org.jetbrains.compose.web.css.px +import org.jetbrains.compose.web.css.width +import org.jetbrains.compose.web.dom.* + +@Composable +fun MixedCard(value: String, selected: Boolean, effect: (String) -> String) { + Div(attrs = { + classes("card", value) + id(effect("id")) + attr("title", "A & B") + if (selected) { + attr("aria-current", effect("current")) + classes("selected") + } + attr("data-value", effect("data")) + }) { Text(effect("body")) } +} + +@Composable +fun MixedOverwrite(first: Boolean, explicit: Boolean, value: String, effect: (String) -> String) { + Div(attrs = { + if (first) attr("title", effect("first")) + attr("aria-label", effect("label")) + classes(value, "card") + attr("title", effect("last")) + if (explicit) attr("class", effect("class")) + }) { Text("body") } +} + +@Composable +fun MixedButton(disabled: Boolean, value: String) { + Button(attrs = { + classes("button") + if (disabled) disabled() + attr("aria-disabled", disabled.toString()) + attr("aria-label", value) + }) { Text(value) } +} + +@Composable +fun MixedFallback(callback: org.jetbrains.compose.web.attributes.AttrsScope<kotlinx.browser.dom.HTMLDivElement>.() -> Unit) { + Div(attrs = { + id("before") + callback() + attr("title", "after") + }) { Text("body") } +} + +@Composable +fun MixedInvalid(value: String, explicit: Boolean) { + Div(attrs = { + classes(value) + if (explicit) attr("class", "override") + }) { Text("body") } +} + +@Composable +fun MixedInput(value: String?, required: Boolean, disabled: Boolean, checked: Boolean, effect: (String) -> String) { + Input(type = if (checked) org.jetbrains.compose.web.attributes.InputType.Radio else org.jetbrains.compose.web.attributes.InputType.Text) { + id(effect("id")) + name("field") + value?.let { attr("value", effect(it)) } + if (required) required() + if (disabled) disabled() + if (checked) attr("checked", "") + attr("aria-required", required.toString()) + } +} + +@Composable +fun MixedInputMemberAttributes(value: String, effect: (String) -> String) { + Input(type = effect("type").let { org.jetbrains.compose.web.attributes.InputType.Text }) { + title(effect("title") + value) + lang(effect("lang")) + inputMode(effect("inputmode")) + } +} + +@Composable +fun MixedControlledInput(checked: Boolean) { + Input(type = org.jetbrains.compose.web.attributes.InputType.Checkbox) { + attr("checked", "") + checked(checked) + } +} + +@Composable +fun MixedBranches(value: String, selected: Boolean) { + Div(attrs = { + if (selected) attr("title", value) else attr("title", "other") + val label = value + " label" + attr("aria-label", label) + }) { Text("body") } +} + +@Composable +fun MixedEncodedClasses(value: String, selected: Boolean) { + Div(attrs = { + classes("a<&\"") + if (selected) classes("b&") + classes(value) + }) { Text("body") } +} + +@Composable +fun MixedLiteralPrefix(selected: Boolean) { + Div(attrs = { + id("card") + attr("title", "<&\"") + if (selected) attr("hidden", "") + attr("lang", "en") + style { property("color", "red") } + }) { Text("") } +} + +@Composable +fun MixedTooltip(id: String) { + Span(attrs = { + id(id) + classes("tooltip-text") + style { property("display", "none") } + }) { Text("tooltip") } +} + +@Composable +fun MixedFractionalStyle(value: Double, reset: Boolean) { + Div(attrs = { + style { + width(value.px) + opacity(value) + property("color", "red", true) + variable("label", "<&\"\r") + property("display", "none") + if (reset) { + property("color", "blue") + property("display", "") + } + } + }) { Text("") } +} + +@Composable +fun MixedStyleBranches(selected: Boolean, value: String?, effect: (String) -> String) { + Div(attrs = { + style { property("color", effect("red")) } + attr("title", effect("title")) + style { property("color", effect("blue")) } + if (selected) style { property("display", effect("none")) } + value?.let { style { variable("label", effect(it)) } } + }) { Text("") } +} + +@Composable +fun MixedStyleOverride(before: Boolean, explicit: Boolean, effect: (String) -> String) { + Div(attrs = { + if (before && explicit) attr("style", effect("override")) + style { property("color", effect("red")) } + if (!before && explicit) attr("style", effect("override")) + }) { Text("") } +} + +@Composable +fun MixedInputStyle(block: StyleScope.() -> Unit) { + Input(type = org.jetbrains.compose.web.attributes.InputType.Text) { + style(block) + } +} + +@Composable +fun MixedSelectStyle(block: StyleScope.() -> Unit) { + Select(attrs = { style(block) }) { Text("") } +} + +@Composable +fun MixedStyleFallback(value: String) { + Div(attrs = { + style { + this@Div.attr("title", value) + property("color", "red") + } + }) { Text("") } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/PreparedElements.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/PreparedElements.kt new file mode 100644 index 0000000000..ec87960a34 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/PreparedElements.kt @@ -0,0 +1,104 @@ +/* + * 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 kotlinx.browser.dom.HTMLDivElement +import kotlinx.browser.dom.HTMLSpanElement +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 + +internal val __htmlAttributes0 = "authored" + +@Composable +fun PreparedAttributes(content: ContentBuilder<HTMLDivElement>?) { + val card = CARD_CLASS + Div(attrs = { + val title = "A & " + "B" + classes(card) + attr("title", title) + }, content = content) +} + +@Composable +fun PreparedContainer(value: String, effect: (String) -> Unit) { + Div(attrs = { classes(CARD_CLASS) }) outer@{ + effect("container") + Text(value) + Span(attrs = { classes("details") }) { + Text(scopedValue(this@outer, effect)) + } + Text("end") + } +} + +@Composable +private fun ElementScope<HTMLSpanElement>.scopedValue( + outer: ElementScope<HTMLDivElement>, + effect: (String) -> Unit, +): String { + // Keep both receiver references in a nested lambda too. + return run { + with(outer) { + with(this@scopedValue) { + effect("text") + "details" + } + } + } +} + +@Composable +fun PreparedDynamicText(value: () -> String) { + Span(attrs = { classes("price") }) { Text(value()) } +} + +class PreparedUser(private val nextName: () -> String) { + val name: String get() = nextName() +} + +@Composable +fun PreparedGetter(user: PreparedUser) { + Span(attrs = { classes("user") }) { Text(user.name) } +} + +@Composable +fun PreparedLiteralText() { + Span(attrs = { classes("label") }) { Text("<&\r😀") } +} + +@Composable +fun PreparedSharedAttributes(value: String) { + Span(attrs = { classes("label") }) { Text(value) } +} + +@Composable +fun OversizedLiteral() { + val ten = "<<<<<<<<<<" + val hundred = "$ten$ten$ten$ten$ten$ten$ten$ten$ten$ten" + val thousand = "$hundred$hundred$hundred$hundred$hundred$hundred$hundred$hundred$hundred$hundred" + Span { Text(thousand + thousand) } +} + +@Composable +fun ContentReturn(skip: Boolean) { + Span(attrs = { classes("return") }) { + Text(if (skip) return@Span else "shown") + } +} + +@Composable +fun DynamicAttributes(effect: () -> String) { + Span(attrs = { id(effect()) }) { Text("text") } +} + +@Composable +fun AttributeEffects(effect: () -> Unit) { + Span(attrs = { effect() }) { Text("text") } +} diff --git a/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/StaticSharing.kt b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/StaticSharing.kt new file mode 100644 index 0000000000..3b489178aa --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/jvmTest/kotlin/org/jetbrains/compose/html/compiler/samples/StaticSharing.kt @@ -0,0 +1,126 @@ +/* + * 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.compose.web.internal.runtime.ComposeWebInternalApi::class) + +package org.jetbrains.compose.html.compiler.samples + +import androidx.compose.runtime.Composable +import kotlinx.browser.dom.HTMLElement +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.LowerableHtmlTag +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.TagElement +import org.jetbrains.compose.web.dom.Text + +internal val sharedInitialization = composeHtmlToString { + SharedLiteral() + EquivalentLiteral() +} + +@Composable +fun SharedLiteral() { + Span(attrs = { classes("label") }) { Text("<&\r😀") } +} + +@Composable +fun EquivalentLiteral() { + Span(attrs = { classes("la" + "bel") }) { Text("<" + "&\r😀") } +} + +@Composable +fun SharedDynamic(value: () -> String) { + Span(attrs = { classes("label") }) { Text(value()) } +} + +@Composable +fun SharedContainer(value: String) { + Span(attrs = { classes("label") }) { + Text(value) + Text("tail") + } +} + +@Composable +fun SharedAttributesOtherTag(value: String) { + Div(attrs = { classes("label") }) { Text(value) } +} + +@Composable +fun SameTagOtherType(value: String) { + GenericDiv(attrs = { classes("label") }) { Text(value) } +} + +@Composable +@LowerableHtmlTag("div") +private fun GenericDiv(attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null) { + TagElement("div", attrs, content) +} + +@Composable +fun DistinctLiteral() { + Span(attrs = { classes("label") }) { Text("<&\r😀") } +} + +@Composable +fun OrderedAttributes(value: String) { + Span(attrs = { + attr("data-a", "a") + attr("data-b", "b") + }) { Text(value) } +} + +@Composable +fun ReorderedAttributes(value: String) { + Span(attrs = { + attr("data-b", "b") + attr("data-a", "a") + }) { Text(value) } +} + +@Composable +fun FirstClassMetadata(value: String) { + Span(attrs = { + attr("class", "explicit") + classes("first") + }) { Text(value) } +} + +@Composable +fun SecondClassMetadata(value: String) { + Span(attrs = { + attr("class", "explicit") + classes("second") + }) { Text(value) } +} + +@Composable +fun SharedFragment() { + Div { + Span { Text("<&\r😀") } + Text("tail") + Text("end") + } +} + +@Composable +fun EquivalentFragment() { + Div { + Span { Text("<" + "&\r😀") } + Text("tail") + Text("end") + } +} + +@Composable +fun FragmentWithoutBoundary() { + Div { + Span { Text("<&\r😀") } + Text("tailend") + } +} diff --git a/html/eap/compiler-plugin/tests/src/webTest/kotlin/org/jetbrains/compose/html/compiler/CompilerHydrationTest.kt b/html/eap/compiler-plugin/tests/src/webTest/kotlin/org/jetbrains/compose/html/compiler/CompilerHydrationTest.kt new file mode 100644 index 0000000000..6512d002c4 --- /dev/null +++ b/html/eap/compiler-plugin/tests/src/webTest/kotlin/org/jetbrains/compose/html/compiler/CompilerHydrationTest.kt @@ -0,0 +1,192 @@ +/* + * 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.compose.web.internal.runtime.ComposeWebInternalApi::class, + kotlin.js.ExperimentalWasmJsInterop::class, +) + +package org.jetbrains.compose.html.compiler + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.browser.dom.HTMLElement +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.promise +import org.jetbrains.compose.html.compiler.samples.LargeStaticHydration +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.hydrateComposable +import org.jetbrains.compose.web.renderComposable +import org.jetbrains.compose.web.internal.runtime.browserDocument +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class CompilerHydrationTest { + @Test + fun streamedLargeFragmentsHydrateAndKeepTheirTextNodesDuringUpdates() = MainScope().promise { + assertEquals(jvmLargeHydrationHtml, composeHtmlToString { LargeStaticHydration(0, {}) }) + for (strict in listOf(false, true)) { + var count by mutableStateOf(0) + val root = browserDocument.createElement("div") as HTMLElement + browserDocument.body!!.appendChild(root) + root.innerHTML = jvmLargeHydrationHtml + val fragment = root.firstChild!! + // Hydration consumes boundary comments and inserts the empty Text call's node. + val nodes = (0 until fragment.childNodes.length).map { fragment.childNodes.item(it)!! } + .filter { it.nodeType.toInt() != 8 } + val composition = hydrateComposable(root, validateStrictly = strict, onHydrationMismatch = { throw it }) { + LargeStaticHydration(count) { count++ } + } + try { + assertSame(fragment, root.firstChild) + nodes.forEach { node -> assertSame(fragment, node.parentNode) } + assertEquals("x".repeat(7_994) + "😀<&\rtailchild", fragment.textContent) + (root.querySelector("#large-increment") as HTMLElement).click() + delay(100) + assertEquals("Count: 1", root.querySelector("#large-count")!!.textContent) + nodes.forEach { node -> assertSame(fragment, node.parentNode) } + } finally { + composition.dispose() + root.parentNode!!.removeChild(root) + } + } + } + + @Test + fun preparedElementsHydrateAndComposeWithTheirOriginalScopes() = MainScope().promise { + assertEquals(jvmPreparedHydrationHtml, composeHtmlToString { PreparedHydrationContent(0, {}) }) + for (hydrate in listOf(false, true)) { + for (strict in listOf(false, true)) { + var count by mutableStateOf(0) + val scopes = mutableListOf<String>() + val root = browserDocument.createElement("div") as HTMLElement + browserDocument.body!!.appendChild(root) + if (hydrate) root.innerHTML = jvmPreparedHydrationHtml + val originalCard = root.querySelector(".card") + val originalPrice = root.querySelector("#prepared-price")?.firstChild + val originalShared = root.querySelectorAll(".shared-literal, .shared-fragment") + .let { nodes -> (0 until nodes.length).map { nodes.item(it) } } + val content: @Composable () -> Unit = { + PreparedHydrationContent(count, { count++ }) { outer -> + DisposableEffect(count) { + val price = scopeElement + val card = with(outer) { scopeElement } + assertEquals("prepared-price", price.id) + assertEquals("card", card.className) + assertSame(card, price.parentNode) + scopes.add("$count:${price.tagName}:${card.tagName}") + onDispose {} + } + } + } + val composition = if (hydrate) { + hydrateComposable(root, validateStrictly = strict, onHydrationMismatch = { throw it }) { content() } + } else { + renderComposable(root = root) { content() } + } + try { + val card = root.querySelector(".card")!! + val price = root.querySelector("#prepared-price")!! + val priceText = price.firstChild + val details = root.querySelector(".direct-details")!! + assertEquals("0", details.getAttribute("data-count")) + val detailsText = details.querySelector(".details")!!.firstChild + val loopText = root.querySelectorAll(".loop-details").let { nodes -> + (0 until nodes.length).map { nodes.item(it)!!.firstChild } + } + val prefix = details.firstChild + val suffix = details.lastChild + val literal = root.querySelector("#prepared-literal")!!.firstChild + val shared = root.querySelectorAll(".shared-literal, .shared-fragment") + .let { nodes -> (0 until nodes.length).map { nodes.item(it)!! } } + assertEquals( + listOf("<& literal", "<& literal", "<& fragment", "<& fragment"), + shared.map { it.textContent }) + if (hydrate) { + assertSame(originalCard, card) + assertSame(originalPrice, priceText) + originalShared.forEachIndexed { index, node -> assertSame(node, shared[index]) } + } + assertEquals(listOf("0:SPAN:DIV"), scopes) + assertEquals("Price: 0", price.textContent) + assertEquals("<& literal", literal!!.textContent) + (root.querySelector("#prepared-increment") as HTMLElement).click() + delay(100) + assertEquals("Price: 1", price.textContent) + assertEquals("Details 1", root.querySelector(".details")!!.textContent) + val updatedLoop = root.querySelectorAll(".loop-details") + loopText.forEachIndexed { index, text -> + assertSame(text, updatedLoop.item(index)!!.firstChild) + assertEquals("Loop $index: 1", text!!.textContent) + } + assertSame(details, root.querySelector(".direct-details")) + assertEquals("1", details.getAttribute("data-count")) + assertSame(detailsText, details.querySelector(".details")!!.firstChild) + assertSame(prefix, details.firstChild) + assertSame(suffix, details.lastChild) + assertEquals(listOf("0:SPAN:DIV", "1:SPAN:DIV"), scopes) + assertSame(card, root.querySelector(".card")) + assertSame(priceText, price.firstChild) + assertSame(literal, root.querySelector("#prepared-literal")!!.firstChild) + val updatedShared = root.querySelectorAll(".shared-literal, .shared-fragment") + shared.forEachIndexed { index, node -> assertSame(node, updatedShared.item(index)) } + } finally { + composition.dispose() + root.parentNode!!.removeChild(root) + } + } + } + } + + @Test + fun compilerGeneratedHtmlHydratesAndUpdates() = MainScope().promise { + assertEquals(jvmHydrationHtml, composeHtmlToString { CompilerHydrationContent(0) {} }) + for (strict in listOf(false, true)) { + var count by mutableStateOf(0) + val root = browserDocument.createElement("div") as HTMLElement + browserDocument.body!!.appendChild(root) + root.innerHTML = jvmHydrationHtml + val nodes = (0 until root.childNodes.length).map { root.childNodes.item(it) } + val title = root.querySelector("#compiler-title")!! + val titleText = title.firstChild + val table = root.querySelector("#constant-table")!! + val tableElements = table.querySelectorAll("*").let { elements -> + (0 until elements.length).map { elements.item(it) } + } + val cell = table.querySelector("td")!! + val firstText = cell.firstChild + val lastText = cell.lastChild + val composition = hydrateComposable(root, validateStrictly = strict, onHydrationMismatch = { throw it }) { + CompilerHydrationContent(count) { count++ } + } + try { + nodes.forEachIndexed { index, node -> assertSame(node, root.childNodes.item(index)) } + assertSame(titleText, title.firstChild) + assertEquals(3, title.childNodes.length) + assertSame(table, root.querySelector("#constant-table")) + assertSame(firstText, cell.firstChild) + assertSame(lastText, cell.lastChild) + assertEquals(3, cell.childNodes.length) + assertEquals("a<&b", cell.textContent) + (nodes[1] as HTMLElement).click() + delay(100) + assertEquals("Count: 1", nodes[2]!!.textContent) + assertSame(titleText, title.firstChild) + assertSame(firstText, cell.firstChild) + assertSame(lastText, cell.lastChild) + val updatedElements = root.querySelectorAll("#constant-table *") + tableElements.forEachIndexed { index, element -> assertSame(element, updatedElements.item(index)) } + } finally { + composition.dispose() + root.parentNode!!.removeChild(root) + } + } + } +} diff --git a/html/eap/core/build.gradle.kts b/html/eap/core/build.gradle.kts index 1d3fc58388..d0feca3697 100644 --- a/html/eap/core/build.gradle.kts +++ b/html/eap/core/build.gradle.kts @@ -50,6 +50,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation("org.jetbrains.compose.html:kotlinx-browser-common-subset:$kotlinxBrowserCommonSubsetVersion") api(project(":internal-html-core-runtime-eap")) + implementation(project(":internal-html-serialization-eap")) } } diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt index 7529b819fd..4db8224e8a 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt @@ -12,7 +12,8 @@ import androidx.compose.runtime.Composable * produces the same HTML as [composeHtmlToString] with the same arguments. * * [chunkSize] defaults to 2048 and is a target minimum measured in UTF-16 code units. Tags and individual `Text` - * calls stay intact, as do elements whose contents require validation. A chunk can exceed [chunkSize], + * calls stay intact on the runtime path. Compiler-validated fragments can stream in smaller pieces. + * Elements awaiting content validation stay buffered. A chunk can exceed [chunkSize], * and no empty chunks are sent. * * Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation. @@ -24,7 +25,7 @@ import androidx.compose.runtime.Composable * @param sink receives each non-empty HTML chunk. * @param chunkSize target minimum chunk size in UTF-16 code units. Defaults to 2048. * @param hydratable whether to emit text-boundary markers needed to hydrate adjacent `Text` calls. - * Set to `false` when the output will not be hydrated. + * Compiler optimization might still emit them. * @param key optional key for reusing rendering storage across calls. * Use a stable key per template. Reuse is best-effort. `null` disables pooling. * @param validateStrictly overrides the default strict-validation setting for this render. 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 9c4f89e239..57643b5377 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 @@ -30,7 +30,7 @@ import org.jetbrains.compose.web.dom.LocalComposeHtmlContext * duplicate foreign-attribute checks. * * @param hydratable whether to emit text-boundary markers needed to hydrate adjacent `Text` - * nodes. Set to `false` when the output will not be hydrated. + * nodes. Compiler optimization might still emit them. * @param key optional key for reusing rendering storage across calls. * Use a stable key per template. Reuse is best-effort. `null` disables pooling. * @param validateStrictly overrides the default strict-validation setting for this render. @@ -44,7 +44,11 @@ fun composeHtmlToString( validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, ): String { - return composeHtmlString(hydratable = hydratable, key = key, content = htmlValidatedContent(validateStrictly, content)) + return composeHtmlString( + hydratable = hydratable, + key = key, + content = htmlValidatedContent(validateStrictly, content) + ) } /** Shared single-pass composition and writer for fragments, documents, and streaming output. */ 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 101e96e124..6e7a1b3e45 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 @@ -2,11 +2,13 @@ package org.jetbrains.compose.web import androidx.compose.runtime.staticCompositionLocalOf +@PublishedApi internal enum class HtmlValidationMode { Fast, Strict, } +@PublishedApi internal val LocalHtmlValidationMode = staticCompositionLocalOf { HtmlValidationMode.Fast } internal fun htmlValidationMode(validateStrictly: Boolean): HtmlValidationMode = 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 177b4bb309..596d30665e 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 @@ -22,7 +22,7 @@ private const val HtmlDoctype = "<!doctype html>" /** * Renders a complete HTML document with optional browser hydration. * - * [content] must produce exactly one `html` element. Output starts with an HTML doctype. + * [content] should produce exactly one `html` element; strict validation checks this. * Output starts with an HTML doctype. * Static documents emit no hydration state. * @@ -65,7 +65,8 @@ fun renderHydratedDocument( * [renderHydratedDocument]. * * [chunkSize] defaults to 2048 UTF-16 code units. Tags and `Text` calls stay intact, so chunks - * may exceed the target. [sink] runs synchronously, may block, and receives no empty chunks. + * may exceed the target. Compiler-validated fragments can stream in smaller pieces. + * [sink] runs synchronously, may block, and receives no empty chunks. * Emitted output cannot be retracted on failure, including document validation after composition. * * Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation. diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt index 9f67fd95ad..aad89b58dd 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt @@ -3,8 +3,12 @@ * 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.attributes +import org.jetbrains.compose.web.dom.LowerableAttribute +import org.jetbrains.compose.web.dom.htmlAutoCompleteValue import org.jetbrains.compose.web.attributes.builders.saveControlledInputState import org.jetbrains.compose.web.events.SyntheticSubmitEvent import kotlinx.browser.dom.HTMLAnchorElement @@ -25,12 +29,15 @@ import kotlinx.browser.dom.HTMLTextAreaElement import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi import kotlin.jvm.JvmName +@LowerableAttribute fun AttrsScope<HTMLAnchorElement>.href(value: String) = attr("href", value) +@LowerableAttribute(conversion = "targetStr", defaultValue = ["_self"]) fun AttrsScope<HTMLAnchorElement>.target(value: ATarget = ATarget.Self) = attr("target", value.targetStr) +@LowerableAttribute(conversion = "relStr") fun AttrsScope<HTMLAnchorElement>.rel(value: ARel) = attr("rel", value.relStr) @@ -42,21 +49,25 @@ fun AttrsScope<HTMLAnchorElement>.rel(value: ARel) = fun AttrsScope<HTMLAnchorElement>.ref(value: ARel) = rel(value) +@LowerableAttribute fun AttrsScope<HTMLAnchorElement>.ping(value: String) = attr("ping", value) fun AttrsScope<HTMLAnchorElement>.ping(vararg urls: String) = attr("ping", urls.joinToString(" ")) +@LowerableAttribute fun AttrsScope<HTMLAnchorElement>.hreflang(value: String) = attr("hreflang", value) +@LowerableAttribute(defaultValue = [""]) fun AttrsScope<HTMLAnchorElement>.download(value: String = "") = attr("download", value) /* Link attributes */ @JvmName("linkHref") +@LowerableAttribute fun AttrsScope<HTMLLinkElement>.href(value: String) = attr("href", value) @@ -68,81 +79,102 @@ fun AttrsScope<HTMLLinkElement>.rel( listOf(value, *additionalValues).joinToString(" ") { it.relStr }, ) +@LowerableAttribute fun AttrsScope<HTMLLinkElement>.type(value: String) = attr("type", value) /* Script attributes */ @JvmName("scriptSrc") +@LowerableAttribute fun AttrsScope<HTMLScriptElement>.src(value: String) = attr("src", value) @JvmName("scriptType") +@LowerableAttribute(conversion = "typeStr") fun AttrsScope<HTMLScriptElement>.type(value: ScriptType) = attr("type", value.typeStr) /* Button attributes */ @JvmName("buttonAutoFocus") +@LowerableAttribute fun AttrsScope<HTMLButtonElement>.autoFocus() = attr("autofocus", "") @JvmName("buttonDisabled") +@LowerableAttribute fun AttrsScope<HTMLButtonElement>.disabled() = attr("disabled", "") @JvmName("buttonForm") +@LowerableAttribute fun AttrsScope<HTMLButtonElement>.form(formId: String) = attr("form", formId) @JvmName("buttonFormAction") +@LowerableAttribute fun AttrsScope<HTMLButtonElement>.formAction(url: String) = attr("formaction", url) +@LowerableAttribute(conversion = "typeStr") fun AttrsScope<HTMLButtonElement>.formEncType(value: ButtonFormEncType) = attr("formenctype", value.typeStr) +@LowerableAttribute(conversion = "methodStr") fun AttrsScope<HTMLButtonElement>.formMethod(value: ButtonFormMethod) = attr("formmethod", value.methodStr) @JvmName("buttonFormNoValidate") +@LowerableAttribute fun AttrsScope<HTMLButtonElement>.formNoValidate() = attr("formnovalidate", "") +@LowerableAttribute(conversion = "targetStr") fun AttrsScope<HTMLButtonElement>.formTarget(value: ButtonFormTarget) = attr("formtarget", value.targetStr) @JvmName("buttonName") +@LowerableAttribute fun AttrsScope<HTMLButtonElement>.name(value: String) = attr("name", value) +@LowerableAttribute(conversion = "str") fun AttrsScope<HTMLButtonElement>.type(value: ButtonType) = attr("type", value.str) @JvmName("buttonValue") +@LowerableAttribute fun AttrsScope<HTMLButtonElement>.value(value: String) = attr("value", value) /* Form attributes */ +@LowerableAttribute fun AttrsScope<HTMLFormElement>.action(value: String) = attr("action", value) +@LowerableAttribute("accept-charset") fun AttrsScope<HTMLFormElement>.acceptCharset(value: String) = attr("accept-charset", value) +@LowerableAttribute(conversion = "onOff", defaultValue = ["on"]) fun AttrsScope<HTMLFormElement>.autoComplete(value: Boolean = true) = - attr("autocomplete", if(value) "on" else "off") + attr("autocomplete", htmlAutoCompleteValue(value)) +@LowerableAttribute(conversion = "typeStr") fun AttrsScope<HTMLFormElement>.encType(value: FormEncType) = attr("enctype", value.typeStr) +@LowerableAttribute(conversion = "methodStr") fun AttrsScope<HTMLFormElement>.method(value: FormMethod) = attr("method", value.methodStr) +@LowerableAttribute fun AttrsScope<HTMLFormElement>.noValidate() = attr("novalidate", "") +@LowerableAttribute(conversion = "targetStr") fun AttrsScope<HTMLFormElement>.target(value: FormTarget) = attr("target", value.targetStr) @@ -162,106 +194,134 @@ fun AttrsScope<HTMLFormElement>.onReset( /* Input attributes */ +@LowerableAttribute(conversion = "typeStr") fun AttrsScope<HTMLInputElement>.type(value: InputType<*>) = attr("type", value.typeStr) +@LowerableAttribute fun AttrsScope<HTMLInputElement>.accept(value: String) = attr("accept", value) // type: file only // JVM erases AttrsScope's element type, so element-specific extensions with otherwise identical signatures need distinct JVM names. // Web targets do not use JVM bytecode signatures, so they have no equivalent signature clash. @JvmName("inputAlt") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.alt(value: String) = attr("alt", value) // type: image only @JvmName("inputAutoComplete") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.autoComplete(value: AutoComplete) = attr("autocomplete", value.toString()) @JvmName("inputAutoFocus") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.autoFocus() = attr("autofocus", "") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.capture(value: String) = attr("capture", value) // type: file only +@LowerableAttribute fun AttrsScope<HTMLInputElement>.dirName(value: String) = attr("dirname", value) // text, search @JvmName("inputDisabled") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.disabled() = attr("disabled", "") @JvmName("inputForm") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.form(id: String) = attr("form", id) @JvmName("inputFormAction") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.formAction(url: String) = attr("formaction", url) +@LowerableAttribute(conversion = "typeStr") fun AttrsScope<HTMLInputElement>.formEncType(value: InputFormEncType) = attr("formenctype", value.typeStr) +@LowerableAttribute(conversion = "methodStr") fun AttrsScope<HTMLInputElement>.formMethod(value: InputFormMethod) = attr("formmethod", value.methodStr) @JvmName("inputFormNoValidate") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.formNoValidate() = attr("formnovalidate", "") +@LowerableAttribute(conversion = "targetStr") fun AttrsScope<HTMLInputElement>.formTarget(value: InputFormTarget) = attr("formtarget", value.targetStr) @JvmName("inputHeight") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.height(value: Int) = attr("height", value.toString()) // image only @JvmName("inputWidth") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.width(value: Int) = attr("width", value.toString()) // image only @JvmName("canvasWidth") +@LowerableAttribute fun AttrsScope<HTMLCanvasElement>.width(value: Int) = attr("width", value.toString()) @JvmName("canvasHeight") +@LowerableAttribute fun AttrsScope<HTMLCanvasElement>.height(value: Int) = attr("height", value.toString()) +@LowerableAttribute fun AttrsScope<HTMLInputElement>.list(dataListId: String) = attr("list", dataListId) +@LowerableAttribute fun AttrsScope<HTMLInputElement>.max(value: String) = attr("max", value) @JvmName("inputMaxLength") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.maxLength(value: Int) = attr("maxlength", value.toString()) +@LowerableAttribute fun AttrsScope<HTMLInputElement>.min(value: String) = attr("min", value) @JvmName("inputMinLength") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.minLength(value: Int) = attr("minlength", value.toString()) @JvmName("inputMultiple") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.multiple() = attr("multiple", "") @JvmName("inputName") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.name(value: String) = attr("name", value) +@LowerableAttribute fun AttrsScope<HTMLInputElement>.pattern(value: String) = attr("pattern", value) @JvmName("inputPlaceholder") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.placeholder(value: String) = attr("placeholder", value) @JvmName("inputReadOnly") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.readOnly() = attr("readonly", "") @@ -271,142 +331,176 @@ fun AttrsScope<HTMLInputElement>.readOnly() = level = DeprecationLevel.WARNING ) @JvmName("inputRequired") +@LowerableAttribute(defaultValue = ["true"]) fun AttrsScope<HTMLInputElement>.required(value: Boolean = true) = attr("required", value.toString()) @JvmName("inputRequired") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.required() = attr("required", "") @JvmName("inputSize") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.size(value: Int) = attr("size", value.toString()) @JvmName("inputSrc") +@LowerableAttribute fun AttrsScope<HTMLInputElement>.src(value: String) = attr("src", value) // image only +@LowerableAttribute fun AttrsScope<HTMLInputElement>.step(value: Number) = attr("step", value.toString()) // numeric types only /* Option attributes */ @JvmName("optionValue") +@LowerableAttribute fun AttrsScope<HTMLOptionElement>.value(value: String) = attr("value", value) @JvmName("optionDisabled") +@LowerableAttribute fun AttrsScope<HTMLOptionElement>.disabled() = attr("disabled", "") +@LowerableAttribute fun AttrsScope<HTMLOptionElement>.selected() = attr("selected", "") @JvmName("optionLabel") +@LowerableAttribute fun AttrsScope<HTMLOptionElement>.label(value: String) = attr("label", value) /* Select attributes */ @JvmName("selectAutoComplete") +@LowerableAttribute fun AttrsScope<HTMLSelectElement>.autoComplete(value: AutoComplete) = attr("autocomplete", value.toString()) +@LowerableAttribute fun AttrsScope<HTMLSelectElement>.autofocus() = attr("autofocus", "") @JvmName("selectDisabled") +@LowerableAttribute fun AttrsScope<HTMLSelectElement>.disabled() = attr("disabled", "") @JvmName("selectForm") +@LowerableAttribute fun AttrsScope<HTMLSelectElement>.form(formId: String) = attr("form", formId) @JvmName("selectMultiple") +@LowerableAttribute fun AttrsScope<HTMLSelectElement>.multiple() = attr("multiple", "") @JvmName("selectName") +@LowerableAttribute fun AttrsScope<HTMLSelectElement>.name(value: String) = attr("name", value) @JvmName("selectRequired") +@LowerableAttribute fun AttrsScope<HTMLSelectElement>.required() = attr("required", "") @JvmName("selectSize") +@LowerableAttribute fun AttrsScope<HTMLSelectElement>.size(numberOfRows: Int) = attr("size", numberOfRows.toString()) /* OptGroup attributes */ @JvmName("optGroupLabel") +@LowerableAttribute fun AttrsScope<HTMLOptGroupElement>.label(value: String) = attr("label", value) @JvmName("optGroupDisabled") +@LowerableAttribute fun AttrsScope<HTMLOptGroupElement>.disabled() = attr("disabled", "") /* TextArea attributes */ @JvmName("textAreaAutoComplete") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.autoComplete(value: AutoComplete) = attr("autocomplete", value.toString()) @JvmName("textAreaAutoFocus") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.autoFocus() = attr("autofocus", "") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.cols(value: Int) = attr("cols", value.toString()) @JvmName("textAreaDisabled") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.disabled() = attr("disabled", "") @JvmName("textAreaForm") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.form(formId: String) = attr("form", formId) @JvmName("textAreaMaxLength") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.maxLength(value: Int) = attr("maxlength", value.toString()) @JvmName("textAreaMinLength") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.minLength(value: Int) = attr("minlength", value.toString()) @JvmName("textAreaName") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.name(value: String) = attr("name", value) @JvmName("textAreaPlaceholder") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.placeholder(value: String) = attr("placeholder", value) @JvmName("textAreaReadOnly") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.readOnly() = attr("readonly", "") @JvmName("textAreaRequired") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.required() = attr("required", "") +@LowerableAttribute fun AttrsScope<HTMLTextAreaElement>.rows(value: Int) = attr("rows", value.toString()) +@LowerableAttribute(conversion = "str") fun AttrsScope<HTMLTextAreaElement>.wrap(value: TextAreaWrap) = attr("wrap", value.str) /* Img attributes */ @JvmName("imageSrc") +@LowerableAttribute fun AttrsScope<HTMLImageElement>.src(value: String): AttrsScope<HTMLImageElement> = attr("src", value) @JvmName("imageAlt") +@LowerableAttribute fun AttrsScope<HTMLImageElement>.alt(value: String): AttrsScope<HTMLImageElement> = attr("alt", value) @@ -437,18 +531,23 @@ internal val setCheckedValue: (HTMLInputElement, Boolean) -> Unit = { e, v -> } /* Img attributes */ +@LowerableAttribute("for") fun AttrsScope<HTMLLabelElement>.forId(value: String): AttrsScope<HTMLLabelElement> = attr("for", value) /* Table attributes */ +@LowerableAttribute fun AttrsScope<HTMLTableColElement>.span(value: Int): AttrsScope<HTMLTableColElement> = attr("span", value.toString()) +@LowerableAttribute(conversion = "str") fun AttrsScope<HTMLTableCellElement>.scope(value: Scope): AttrsScope<HTMLTableCellElement> = attr("scope", value.str) +@LowerableAttribute fun AttrsScope<HTMLTableCellElement>.colspan(value: Int): AttrsScope<HTMLTableCellElement> = attr("colspan", value.toString()) +@LowerableAttribute fun AttrsScope<HTMLTableCellElement>.rowspan(value: Int): AttrsScope<HTMLTableCellElement> = attr("rowspan", value.toString()) 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 322b305e9f..6608b5d959 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 @@ -3,8 +3,11 @@ * 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.attributes +import org.jetbrains.compose.web.dom.LowerableAttribute import androidx.compose.runtime.DisposableEffectResult import androidx.compose.runtime.DisposableEffectScope import org.jetbrains.compose.web.css.StyleScope @@ -66,24 +69,43 @@ interface AttrsScope<out TElement : Element> : EventsListenerScope { */ fun classes(vararg classes: String) = classes(classes.asList()) + @LowerableAttribute fun id(value: String) = attr(ID, value) + + @LowerableAttribute(value = "true") fun hidden() = attr(HIDDEN, true.toString()) + + @LowerableAttribute fun title(value: String) = attr(TITLE, value) + + @LowerableAttribute(conversion = "dirStr") fun dir(value: DirType) = attr(DIR, value.dirStr) + + @LowerableAttribute(conversion = "str") fun draggable(value: Draggable) = attr(DRAGGABLE, value.str) + + @LowerableAttribute fun contentEditable(value: Boolean) = attr(CONTENT_EDITABLE, value.toString()) + + @LowerableAttribute fun lang(value: String) = attr(LANG, value) + + @LowerableAttribute fun tabIndex(value: Int) = attr(TAB_INDEX, value.toString()) + + @LowerableAttribute fun spellCheck(value: Boolean) = attr(SPELLCHECK, value.toString()) /** * see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inputmode */ + @LowerableAttribute fun inputMode(value: String) = attr("inputmode", value) /** * see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inputmode */ + @LowerableAttribute(conversion = "str") fun inputMode(value: InputMode) = attr("inputmode", value.str) /** @@ -336,7 +358,7 @@ open class AttrsScopeBuilder<TElement : Element>( } require( normalizedName !in hydrationProtocolAttributes && - (!sourceOwnsName || !destinationHasName) + (!sourceOwnsName || !destinationHasName) ) { "Attribute \"$name\" is owned by the Compose hydration protocol" } diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/builders/InputAttrsScope.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/builders/InputAttrsScope.kt index 41e9f208fc..6638142315 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/builders/InputAttrsScope.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/attributes/builders/InputAttrsScope.kt @@ -7,6 +7,7 @@ package org.jetbrains.compose.web.attributes.builders +import org.jetbrains.compose.web.dom.inputValueIsAttribute import androidx.compose.web.events.SyntheticEvent import org.jetbrains.compose.web.attributes.* import org.jetbrains.compose.web.attributes.EventsListenerScope.Companion.BEFOREINPUT @@ -45,13 +46,7 @@ class InputAttrsScope<ValueType>( ) : AttrsScope<HTMLInputElement> by attrsScope { fun value(value: String): InputAttrsScope<ValueType> { - when (inputType) { - InputType.Checkbox, - InputType.Radio, - InputType.Hidden, - InputType.Submit -> attr("value", value) - else -> prop(setInputValue, value) - } + if (inputValueIsAttribute(inputType)) attr("value", value) else prop(setInputValue, value) return this } diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt index 555e51ee47..0706c99fd9 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt @@ -3,6 +3,11 @@ * 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) + +// Access shared serialization helpers without exposing them as public API. +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package org.jetbrains.compose.web.dom import androidx.compose.runtime.Composable @@ -45,6 +50,15 @@ internal interface ComposeHtmlContext { content: RawTextContent, ) + @Composable + @NonRestartableComposable + fun <TElement : Element> DynamicTextTagElement( + attributes: PreparedHtmlAttributes<TElement>, + value: @Composable ElementScope<TElement>.() -> String, + ) { + TagElement(attributes.elementBuilder, attributes.callback) { TextElement(value()) } + } + @Composable @NonRestartableComposable fun TextElement(value: String) @@ -192,66 +206,3 @@ internal class RawTextContent private constructor( } } } - -internal fun String.normalizeHtmlInputCharacters(): String = - if ('\r' in this || '\u0000' in this) { - replace("\r\n", "\n") - .replace('\r', '\n') - .replace('\u0000', '\uFFFD') - } else { - this - } - -internal fun String.rawTextTagIndex( - tagName: String, - closing: Boolean, - startIndex: Int = 0, - endIndex: Int = length, -): Int { - var index = indexOf('<', startIndex) - while (index >= 0 && index < endIndex) { - val nameStart = index + if (closing) 2 else 1 - val delimiterIndex = nameStart + tagName.length - if ( - delimiterIndex < length && - (!closing || this[index + 1] == '/') && - matchesRawTextTagName(nameStart, tagName) && - this[delimiterIndex] in "\t\n\u000C\r />" - ) { - return index - } - index = indexOf('<', index + 1) - } - return -1 -} - -private fun requireValidRawTextContent(tagName: String, content: String) { - require(isHtmlRawTextElement(tagName, HtmlNamespace)) { - "Raw text content is not supported for <$tagName>" - } - require(content.rawTextTagIndex(tagName, closing = true) < 0) { - "Raw text for <$tagName> must not contain a </$tagName end tag" - } - - if (tagName == "script") { - var escapedStart = content.indexOf("<!--") - while (escapedStart >= 0) { - // Include the opener's dashes: <!--> also exits the escaped state. - val escapedEnd = content.indexOf("-->", escapedStart + 2) - // Limit the tag scan to this segment so each character is checked at most once. - val script = content.rawTextTagIndex( - tagName = "script", - closing = false, - startIndex = escapedStart + 4, - endIndex = if (escapedEnd < 0) content.length else escapedEnd + 1, - ) - require(script < 0) { - "Raw text for <script> must not contain a <script tag inside <!-- escaped text" - } - if (escapedEnd < 0) break - escapedStart = content.indexOf("<!--", escapedEnd + 3) - } - } -} - -internal expect fun String.matchesRawTextTagName(start: Int, tagName: String): Boolean diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/ElementBuilder.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/ElementBuilder.kt index b1be972c52..df9899cdac 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/ElementBuilder.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/ElementBuilder.kt @@ -3,19 +3,20 @@ * 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.web.dom import kotlinx.browser.dom.Element import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi -internal const val HtmlNamespace = "http://www.w3.org/1999/xhtml" - fun interface ElementBuilder<TElement : Element> { /** The HTML tag name used for string rendering. DOM-only lambda builders may omit it. */ val tagName: String get() = error( "String rendering requires a tag name. " + - "Override ElementBuilder.tagName or use TagElement(tagName, ...)." + "Override ElementBuilder.tagName or use TagElement(tagName, ...)." ) fun create(): TElement diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Elements.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Elements.kt index c882fe41eb..6be88c8ec7 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Elements.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Elements.kt @@ -3,6 +3,8 @@ * 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 @@ -218,6 +220,7 @@ inline fun Style( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Html( attrs: AttrBuilderContext<HTMLHtmlElement>? = null, content: ContentBuilder<HTMLHtmlElement>? = null, @@ -226,6 +229,7 @@ fun Html( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Head( attrs: AttrBuilderContext<HTMLHeadElement>? = null, content: ContentBuilder<HTMLHeadElement>? = null, @@ -234,6 +238,7 @@ fun Head( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Body( attrs: AttrBuilderContext<HTMLBodyElement>? = null, content: ContentBuilder<HTMLBodyElement>? = null, @@ -242,6 +247,7 @@ fun Body( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Title( attrs: AttrBuilderContext<HTMLTitleElement>? = null, content: ContentBuilder<HTMLTitleElement>? = null, @@ -250,6 +256,7 @@ fun Title( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Meta( attrs: AttrBuilderContext<HTMLMetaElement>? = null, ) = TagElement<HTMLMetaElement>(MetaBuilder, attrs, content = null) @@ -257,6 +264,7 @@ fun Meta( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Link( attrs: AttrBuilderContext<HTMLLinkElement>? = null, ) = TagElement<HTMLLinkElement>(LinkBuilder, attrs, content = null) @@ -264,6 +272,7 @@ fun Link( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Script( attrs: AttrBuilderContext<HTMLScriptElement>? = null, ) = TagElement<HTMLScriptElement>(ScriptBuilder, attrs, content = null) @@ -300,6 +309,7 @@ fun Script( @Composable @NonRestartableComposable +@LowerableHtmlTag fun Div( attrs: AttrBuilderContext<HTMLDivElement>? = null, content: ContentBuilder<HTMLDivElement>? = null, @@ -308,6 +318,7 @@ fun Div( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Span( attrs: AttrBuilderContext<HTMLSpanElement>? = null, content: ContentBuilder<HTMLSpanElement>? = null, @@ -316,6 +327,7 @@ fun Span( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Address( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -324,6 +336,7 @@ fun Address( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Article( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -332,6 +345,7 @@ fun Article( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Aside( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -340,6 +354,7 @@ fun Aside( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Header( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -348,6 +363,7 @@ fun Header( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Section( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -356,6 +372,7 @@ fun Section( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Nav( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -364,6 +381,7 @@ fun Nav( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Main( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -372,6 +390,7 @@ fun Main( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Footer( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -380,6 +399,7 @@ fun Footer( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun H1( attrs: AttrBuilderContext<HTMLHeadingElement>? = null, content: ContentBuilder<HTMLHeadingElement>? = null, @@ -388,6 +408,7 @@ fun H1( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun H2( attrs: AttrBuilderContext<HTMLHeadingElement>? = null, content: ContentBuilder<HTMLHeadingElement>? = null, @@ -396,6 +417,7 @@ fun H2( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun H3( attrs: AttrBuilderContext<HTMLHeadingElement>? = null, content: ContentBuilder<HTMLHeadingElement>? = null, @@ -404,6 +426,7 @@ fun H3( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun H4( attrs: AttrBuilderContext<HTMLHeadingElement>? = null, content: ContentBuilder<HTMLHeadingElement>? = null, @@ -412,6 +435,7 @@ fun H4( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun H5( attrs: AttrBuilderContext<HTMLHeadingElement>? = null, content: ContentBuilder<HTMLHeadingElement>? = null, @@ -420,6 +444,7 @@ fun H5( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun H6( attrs: AttrBuilderContext<HTMLHeadingElement>? = null, content: ContentBuilder<HTMLHeadingElement>? = null, @@ -428,6 +453,7 @@ fun H6( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun P( attrs: AttrBuilderContext<HTMLParagraphElement>? = null, content: ContentBuilder<HTMLParagraphElement>? = null, @@ -436,6 +462,7 @@ fun P( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Em( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -444,6 +471,7 @@ fun Em( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun I( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -452,6 +480,7 @@ fun I( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun B( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -460,6 +489,7 @@ fun B( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Small( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -468,6 +498,7 @@ fun Small( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Sup( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -476,6 +507,7 @@ fun Sup( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Sub( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -484,6 +516,7 @@ fun Sub( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Blockquote( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -492,6 +525,7 @@ fun Blockquote( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Pre( attrs: AttrBuilderContext<HTMLPreElement>? = null, content: ContentBuilder<HTMLPreElement>? = null, @@ -500,6 +534,7 @@ fun Pre( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Code( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -508,6 +543,7 @@ fun Code( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Ul( attrs: AttrBuilderContext<HTMLUListElement>? = null, content: ContentBuilder<HTMLUListElement>? = null, @@ -516,6 +552,7 @@ fun Ul( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Ol( attrs: AttrBuilderContext<HTMLOListElement>? = null, content: ContentBuilder<HTMLOListElement>? = null, @@ -524,6 +561,7 @@ fun Ol( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Li( attrs: AttrBuilderContext<HTMLLIElement>? = null, content: ContentBuilder<HTMLLIElement>? = null, @@ -532,6 +570,7 @@ fun Li( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag("dl") fun DList( attrs: AttrBuilderContext<HTMLDListElement>? = null, content: ContentBuilder<HTMLDListElement>? = null, @@ -540,6 +579,7 @@ fun DList( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag("dt") fun DTerm( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -548,6 +588,7 @@ fun DTerm( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag("dd") fun DDescription( attrs: AttrBuilderContext<HTMLElement>? = null, content: ContentBuilder<HTMLElement>? = null, @@ -556,6 +597,7 @@ fun DDescription( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Audio( attrs: AttrBuilderContext<HTMLAudioElement>? = null, content: ContentBuilder<HTMLAudioElement>? = null, @@ -564,6 +606,7 @@ fun Audio( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Video( attrs: AttrBuilderContext<HTMLVideoElement>? = null, content: ContentBuilder<HTMLVideoElement>? = null, @@ -572,6 +615,7 @@ fun Video( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Picture( attrs: AttrBuilderContext<HTMLPictureElement>? = null, content: ContentBuilder<HTMLPictureElement>? = null, @@ -580,6 +624,7 @@ fun Picture( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Canvas( attrs: AttrBuilderContext<HTMLCanvasElement>? = null, content: ContentBuilder<HTMLCanvasElement>? = null, @@ -588,6 +633,7 @@ fun Canvas( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag("map") fun HTMLMap( attrs: AttrBuilderContext<HTMLMapElement>? = null, content: ContentBuilder<HTMLMapElement>? = null, @@ -596,6 +642,7 @@ fun HTMLMap( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Datalist( attrs: AttrBuilderContext<HTMLDataListElement>? = null, content: ContentBuilder<HTMLDataListElement>? = null, @@ -604,6 +651,7 @@ fun Datalist( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Fieldset( attrs: AttrBuilderContext<HTMLFieldSetElement>? = null, content: ContentBuilder<HTMLFieldSetElement>? = null, @@ -612,6 +660,7 @@ fun Fieldset( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Legend( attrs: AttrBuilderContext<HTMLLegendElement>? = null, content: ContentBuilder<HTMLLegendElement>? = null, @@ -620,6 +669,7 @@ fun Legend( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Meter( attrs: AttrBuilderContext<HTMLMeterElement>? = null, content: ContentBuilder<HTMLMeterElement>? = null, @@ -628,6 +678,7 @@ fun Meter( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Output( attrs: AttrBuilderContext<HTMLOutputElement>? = null, content: ContentBuilder<HTMLOutputElement>? = null, @@ -636,6 +687,7 @@ fun Output( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Progress( attrs: AttrBuilderContext<HTMLProgressElement>? = null, content: ContentBuilder<HTMLProgressElement>? = null, @@ -649,6 +701,7 @@ fun Progress( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Iframe( attrs: AttrBuilderContext<HTMLIFrameElement>? = null, content: ContentBuilder<HTMLIFrameElement>? = null, @@ -657,6 +710,7 @@ fun Iframe( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Object( attrs: AttrBuilderContext<HTMLObjectElement>? = null, content: ContentBuilder<HTMLObjectElement>? = null, @@ -665,6 +719,7 @@ fun Object( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Table( attrs: AttrBuilderContext<HTMLTableElement>? = null, content: ContentBuilder<HTMLTableElement>? = null, @@ -673,6 +728,7 @@ fun Table( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Caption( attrs: AttrBuilderContext<HTMLTableCaptionElement>? = null, content: ContentBuilder<HTMLTableCaptionElement>? = null, @@ -681,6 +737,7 @@ fun Caption( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Colgroup( attrs: AttrBuilderContext<HTMLTableColElement>? = null, content: ContentBuilder<HTMLTableColElement>? = null, @@ -689,6 +746,7 @@ fun Colgroup( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Tr( attrs: AttrBuilderContext<HTMLTableRowElement>? = null, content: ContentBuilder<HTMLTableRowElement>? = null, @@ -697,6 +755,7 @@ fun Tr( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Thead( attrs: AttrBuilderContext<HTMLTableSectionElement>? = null, content: ContentBuilder<HTMLTableSectionElement>? = null, @@ -705,6 +764,7 @@ fun Thead( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Th( attrs: AttrBuilderContext<HTMLTableCellElement>? = null, content: ContentBuilder<HTMLTableCellElement>? = null, @@ -713,6 +773,7 @@ fun Th( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Td( attrs: AttrBuilderContext<HTMLTableCellElement>? = null, content: ContentBuilder<HTMLTableCellElement>? = null, @@ -721,6 +782,7 @@ fun Td( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Tbody( attrs: AttrBuilderContext<HTMLTableSectionElement>? = null, content: ContentBuilder<HTMLTableSectionElement>? = null, @@ -729,6 +791,7 @@ fun Tbody( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Tfoot( attrs: AttrBuilderContext<HTMLTableSectionElement>? = null, content: ContentBuilder<HTMLTableSectionElement>? = null, @@ -737,6 +800,7 @@ fun Tfoot( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Button( attrs: AttrBuilderContext<HTMLButtonElement>? = null, content: ContentBuilder<HTMLButtonElement>? = null, @@ -841,6 +905,7 @@ fun <K> Input(type: InputType<K>) { */ @Composable @NonRestartableComposable +@LowerableHtmlTag("textarea", afterAttrs = ["value"]) fun TextArea( value: String? = null, attrs: (TextAreaAttrsScope.() -> Unit)? = null, @@ -883,6 +948,7 @@ fun TextArea( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Area( attrs: AttrBuilderContext<HTMLAreaElement>? = null, content: ContentBuilder<HTMLAreaElement>? = null, @@ -891,6 +957,7 @@ fun Area( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Track( attrs: AttrBuilderContext<HTMLTrackElement>? = null, content: ContentBuilder<HTMLTrackElement>? = null, @@ -899,6 +966,7 @@ fun Track( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Embed( attrs: AttrBuilderContext<HTMLEmbedElement>? = null, content: ContentBuilder<HTMLEmbedElement>? = null, @@ -907,6 +975,7 @@ fun Embed( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Param( attrs: AttrBuilderContext<HTMLParamElement>? = null, content: ContentBuilder<HTMLParamElement>? = null, @@ -915,6 +984,7 @@ fun Param( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Source( attrs: AttrBuilderContext<HTMLSourceElement>? = null, content: ContentBuilder<HTMLSourceElement>? = null, @@ -923,6 +993,7 @@ fun Source( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Br( attrs: AttrBuilderContext<HTMLBRElement>? = null, ) = TagElement<HTMLBRElement>(BrBuilder, attrs, content = null) @@ -930,6 +1001,7 @@ fun Br( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Hr( attrs: AttrBuilderContext<HTMLHRElement>? = null, ) = TagElement<HTMLHRElement>(HrBuilder, attrs, content = null) @@ -937,6 +1009,7 @@ fun Hr( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Col( attrs: AttrBuilderContext<HTMLTableColElement>? = null, ) = TagElement<HTMLTableColElement>(ColBuilder, attrs, content = null) @@ -944,6 +1017,7 @@ fun Col( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun A( href: String? = null, attrs: AttrBuilderContext<HTMLAnchorElement>? = null, @@ -964,6 +1038,7 @@ fun A( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag(defaults = ["alt="]) fun Img( src: String, alt: String = "", @@ -982,6 +1057,7 @@ fun Img( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag(omitEmpty = ["action"]) fun Form( action: String? = null, attrs: AttrBuilderContext<HTMLFormElement>? = null, @@ -1002,6 +1078,7 @@ fun Form( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag(defaults = ["multiple=false"]) fun Select( attrs: (SelectAttrsScope.() -> Unit)? = null, multiple: Boolean = false, @@ -1022,6 +1099,7 @@ fun Select( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Option( value: String, attrs: AttrBuilderContext<HTMLOptionElement>? = null, @@ -1040,6 +1118,7 @@ fun Option( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag("optgroup") fun OptGroup( label: String, attrs: AttrBuilderContext<HTMLOptGroupElement>? = null, @@ -1058,6 +1137,7 @@ fun OptGroup( @Composable @NonRestartableComposable @androidx.compose.runtime.ExplicitGroupsComposable +@LowerableHtmlTag fun Label( forId: String? = null, attrs: AttrBuilderContext<HTMLLabelElement>? = null, 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 b977caba32..870c99e731 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 @@ -3,16 +3,10 @@ * 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 +// Access shared serialization helpers without exposing them as public API. +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") -private val HtmlTableChildNames = mapOf( - "table" to setOf("caption", "colgroup", "thead", "tbody", "tfoot", "script", "style", "template"), - "thead" to setOf("tr", "script", "style", "template"), - "tbody" to setOf("tr", "script", "style", "template"), - "tfoot" to setOf("tr", "script", "style", "template"), - "tr" to setOf("td", "th", "script", "style", "template"), - "colgroup" to setOf("col", "template"), -) +package org.jetbrains.compose.web.dom /** Rejects implied table wrappers and foster parenting before HTML leaves the string renderer. */ internal fun requireHtmlParserStableTableChild(parentTagName: String?, child: StringHtmlNode) { @@ -21,36 +15,9 @@ internal fun requireHtmlParserStableTableChild(parentTagName: String?, child: St is StringHtmlTextNode -> { requireHtmlParserStableTableText(parentTagName, child.text) } + is StringHtmlRawTextNode -> { requireHtmlParserStableTableText(parentTagName, child.content.text) } } } - -internal fun requireHtmlParserStableTableElement(parentTagName: String?, childTagName: String?) { - val allowedChildren = HtmlTableChildNames[parentTagName] ?: return - if (childTagName in allowedChildren) return - - val suggestion = when { - parentTagName == "table" && childTagName == "tr" -> "wrap rows in Tbody { }" - parentTagName == "table" && (childTagName == "td" || childTagName == "th") -> - "wrap rows in Tbody { } and cells in Tr { }" - parentTagName == "table" && childTagName == "col" -> "wrap columns in Colgroup { }" - parentTagName in setOf("tbody", "thead", "tfoot") && - (childTagName == "td" || childTagName == "th") -> "wrap cells in Tr { }" - else -> "use explicit table sections, rows and cells, or move this content outside the table" - } - throw IllegalArgumentException( - "Element <$childTagName> cannot be serialized directly inside <$parentTagName>; " + - "the HTML parser would insert wrappers, move or discard content. $suggestion", - ) -} - -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. " + - "Wrap text in Td { } or Th { } inside Tr { }, or 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 index da8bc6bebb..549af05ee8 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.kt @@ -3,6 +3,9 @@ * 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.web.dom import org.jetbrains.compose.web.HydrationAttributePrefix @@ -11,64 +14,6 @@ import org.jetbrains.compose.web.attributes.AttrsScopeBuilder import org.jetbrains.compose.web.attributes.validateClassTokens import org.jetbrains.compose.web.css.toStyleAttributeValue -// HTML parsing merges adjacent non-empty text nodes. This comment preserves their boundary. -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") - -internal fun isHtmlLeadingNewlineElement(tagName: String, namespace: String): Boolean = - namespace == HtmlNamespace && tagName in HtmlLeadingNewlineElementNames - -// 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<String, String>, ) { @@ -202,35 +147,23 @@ internal fun StringBuilder.appendEndTag( append("</").append(tagName).append('>') } -internal fun StringBuilder.appendHydrationTextBoundaryMarker() { - append("<!--").append(HydrationTextBoundaryMarker).append("-->") -} - internal fun requireValidHtmlTagName(name: String) { require(isValidHtmlTagName(name)) { "Invalid HTML tag name: \"$name\"" } } -internal fun isValidHtmlTagName(name: String): Boolean = - name.firstOrNull()?.isAsciiLetter() == true && - name.none { it in AsciiWhitespaceCharacters || it in InvalidHtmlTagNameCharacters } +private const val InvalidHtmlAttributeNameCharacters = " \"'/>=" private fun requireValidHtmlAttributeName(name: String) { require( name.isNotEmpty() && - name.none { it.isISOControl() || it in InvalidHtmlAttributeNameCharacters } + 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) { @@ -247,55 +180,65 @@ private fun String.hasSameHtmlParserAttributeName(other: String): Boolean { return true } -private fun StringBuilder.appendEscapedAttribute(value: String) { - appendEscaped(value, attribute = true) -} - -internal fun StringBuilder.appendEscapedText(value: String) { - appendEscaped(value, attribute = false) -} - // Benchmark-based heuristic: keep short strings on the simpler escaping path. internal const val HtmlEscapeTableMinLength = 256 // Append ordinary runs in bulk. Besides avoiding an append per character, this lets the // JVM copy Latin-1 and UTF-16 runs with its native StringBuilder implementation. -private fun StringBuilder.appendEscaped(value: String, attribute: Boolean) { - if (value.length >= HtmlEscapeTableMinLength && appendEscapedHtmlString(value, attribute)) return - if (canAppendUnescapedHtmlString(value, attribute)) { +// Keep text and attribute scans separate so the inner loop has no mode branch. +internal fun StringBuilder.appendEscapedAttribute(value: String) { + if (value.length >= HtmlEscapeTableMinLength && appendEscapedHtmlString(value, attribute = true)) return + if (canAppendUnescapedHtmlString(value, attribute = true)) { append(value) return } 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 + val character = value[index] + val replacement = htmlAttributeEscapeReplacement(character) + if (replacement != null) { + appendHtmlRun(value, start, index) + append(replacement) + start = index + 1 + // Retry after ampersands only, so suffix searches cover disjoint ranges. + if (character == '&' && canAppendUnescapedHtmlString(value, attribute = true, startIndex = start)) { + appendHtmlRun(value, start, value.length) + return + } } + } + if (start == 0) append(value) else appendHtmlRun(value, start, value.length) +} + +internal fun StringBuilder.appendEscapedText(value: String) { + if (value.length >= HtmlEscapeTableMinLength && appendEscapedHtmlString(value, attribute = false)) return + if (canAppendUnescapedHtmlString(value, attribute = false)) { + append(value) + return + } + var start = 0 + for (index in value.indices) { + val character = value[index] + val replacement = htmlTextEscapeReplacement(character) if (replacement != null) { appendHtmlRun(value, start, index) append(replacement) start = index + 1 + // Retry after ampersands only, so suffix searches cover disjoint ranges. + if (character == '&' && canAppendUnescapedHtmlString(value, attribute = false, startIndex = start)) { + appendHtmlRun(value, start, value.length) + return + } } } if (start == 0) append(value) else appendHtmlRun(value, start, value.length) } -/** Handles long strings on platforms with a specialized serializer. */ -internal expect fun StringBuilder.appendEscapedHtmlString(value: String, attribute: Boolean): Boolean +/** A bulk-copy fast path where the platform supports it. */ +internal expect fun canAppendUnescapedHtmlString(value: String, attribute: Boolean, startIndex: Int = 0): Boolean -/** A native search fast path where the platform supports it. */ -internal expect fun canAppendUnescapedHtmlString(value: String, attribute: Boolean): Boolean +/** Appends the entire escaped value when a platform has a specialized serializer. */ +internal expect fun StringBuilder.appendEscapedHtmlString(value: String, attribute: Boolean): Boolean /** Per renderer, bounded, and independent of attribute values or protocol ownership. */ internal class HtmlAttributeNameCache { diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringWriterContext.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringWriterContext.kt index 759cd61b62..e444f01fd9 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringWriterContext.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringWriterContext.kt @@ -3,6 +3,11 @@ * 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) + +// Access shared serialization helpers without exposing them as public API. +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package org.jetbrains.compose.web.dom import androidx.compose.runtime.Composable @@ -15,6 +20,9 @@ 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.css.StyleScope +import org.jetbrains.compose.web.css.StyleScopeBuilder +import org.jetbrains.compose.web.css.toStyleAttributeValue import org.jetbrains.compose.web.internal.unsafeCast /** @@ -24,10 +32,11 @@ import org.jetbrains.compose.web.internal.unsafeCast * The fields describe the currently open element. Parent state stays in local variables on the * call stack. */ +@PublishedApi internal class HtmlStringWriterContext( private val hydratable: Boolean, private val attrsBuilders: AttrsBuilderPool = AttrsBuilderPool(), - private val output: StringBuilder = StringBuilder(), + @PublishedApi internal val output: StringBuilder = StringBuilder(), private val chunkSink: ((StringBuilder) -> Unit)? = null, private val chunkSize: Int = 2048, ) : ComposeHtmlContext { @@ -38,6 +47,7 @@ internal class HtmlStringWriterContext( private set private val elementScope = StringElementScope<Element>() private var finished = false + private var immutableOutput: String? = null // These elements must keep their content in the shared buffer until their end tag is written. private var bufferedElementDepth = 0 @@ -46,21 +56,21 @@ internal class HtmlStringWriterContext( 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 + internal var currentTagName: String? = null + internal var currentNamespace = HtmlNamespace + internal var previousSiblingWasText = false // The HTML parser drops the first LF in pre, textarea, and listing. Repair it when the // first content is written, so these elements can stream without retaining their children. - private var pendingLeadingNewline = false + internal var pendingLeadingNewline = false // Don't produce children's HTML, but still execute their composables. - private var discardingContent = false + internal var discardingContent = 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() + internal var collectingRawText = false + internal var hasRawTextCall = false + internal val rawText = StringBuilder() // Hydrated document renderers require exactly one html root element. private var rootChildCount = 0 @@ -71,10 +81,8 @@ internal class HtmlStringWriterContext( override fun <TElement : Element> elementBuilder(tagName: String): ElementBuilder<TElement> = attrsBuilders.elementBuilder(tagName, HtmlNamespace) - override fun <TElement : Element> elementBuilderNS( - tagName: String, - namespace: String, - ): ElementBuilder<TElement> = attrsBuilders.elementBuilder(tagName, namespace) + override fun <TElement : Element> elementBuilderNS(tagName: String, namespace: String): ElementBuilder<TElement> = + attrsBuilders.elementBuilder(tagName, namespace) @Composable @NonRestartableComposable @@ -127,7 +135,7 @@ internal class HtmlStringWriterContext( flushChunk() // For buffered elements this index stays valid until appendEndTag inspects the content. val contentStart = output.length - if (!contentOverride.isNullOrEmpty()) writeText(contentOverride, validate) + if (!contentOverride.isNullOrEmpty()) appendText(contentOverride, validate) discardingContent = isVoid || contentOverride != null if (rawTextContent != null && !discardingContent) { rawTextContent.validateScriptSource(startTag.hasScriptSource) @@ -149,34 +157,261 @@ internal class HtmlStringWriterContext( @Composable @NonRestartableComposable @ExplicitGroupsComposable - override fun <TElement : Element> RawTextElement( - tagName: String, - applyAttrs: (AttrsScope<TElement>.() -> Unit)?, - content: RawTextContent, + override fun <TElement : Element> DynamicTextTagElement( + attributes: PreparedHtmlAttributes<TElement>, + value: @Composable ElementScope<TElement>.() -> String, ) { - TagElement(elementBuilder<TElement>(tagName), applyAttrs, rawTextContent = content, content = null) + checkNotFinished() + if (discardingContent) { + value(elementScope()) + return + } + val tagName = attributes.tagName + val validate = LocalHtmlValidationMode.current == HtmlValidationMode.Strict + writeStartTag(tagName, HtmlNamespace, attributes.callback, validate, true) + val parent = enter(tagName, HtmlNamespace, isHtmlLeadingNewlineElement(tagName, HtmlNamespace)) + flushChunk() + val start = output.length + val text = value(elementScope()) + if (text.isNotEmpty()) { + appendText(text, validate) + flushChunk() + } + output.appendEndTag(tagName, HtmlNamespace, start) + exit(parent) + flushChunk() } - @Composable - @NonRestartableComposable - @ExplicitGroupsComposable - override fun TextElement(value: String) { + /** Returns the shared buffer, or null when output is discarded or the stored string can be returned directly. */ + @PublishedApi + internal fun beginStaticHtml(rootTag: String, encoded: String, validate: Boolean): StringBuilder? { + if (!beginStaticHtmlFragment(rootTag, validate)) return null + // Return small static pages directly. Buffer mixed or streamed output. + if (!streaming && currentTagName == null && output.isEmpty()) { + immutableOutput = encoded + return null + } + return output + } + + /** Begins one compiler-validated fragment, independently of how its HTML is stored. */ + @PublishedApi + internal fun beginStaticHtmlFragment(rootTag: String, validate: Boolean): Boolean { + checkNotFinished() + if (discardingContent) return false + beginChild(rootTag, validate) + previousSiblingWasText = false + pendingLeadingNewline = false + return true + } + + /** Inlines each literal append and streams it through the ordinary buffer. */ + @PublishedApi + @Suppress("NOTHING_TO_INLINE") + internal inline fun appendStaticHtmlChunk(encoded: String) { + output.append(encoded) + flushChunk() + } + + /** + * Only called by compiler plugin. Opens nested elements written as one literal. + * Only [rootTag] is checked against the unknown parent; [innermost] receives the following content. + * [closeElement] with the returned parent restores the state from before the region. + */ + @PublishedApi + internal fun openLoweredRegion( + rootTag: String, + encoded: String, + innermost: String, + validate: Boolean, + namespace: String = HtmlNamespace, + leadingNewline: Boolean = false, + ): Any? { + checkNotFinished() + if (discardingContent) return null + beginChild(rootTag, validate) + output.append(encoded) + val parent = enter(innermost, namespace, leadingNewline) + flushChunk() + return parent + } + + /** Only called by compiler plugin. The literal ends with a tag inside the region, so no text boundary is pending. */ + @PublishedApi + internal fun advanceLoweredRegion( + encoded: String, + innermost: String, + namespace: String = HtmlNamespace, + leadingNewline: Boolean = false, + ) { + checkNotFinished() + if (discardingContent) return + output.append(encoded) + currentTagName = innermost + currentNamespace = namespace + previousSiblingWasText = false + pendingLeadingNewline = leadingNewline + flushChunk() + } + + /** + * Only called by compiler plugin. Begins an element whose attributes are written directly; + * their names and syntax have already been validated by the compiler. + */ + @PublishedApi + internal fun beginMixedElement(tagName: String, validate: Boolean): Boolean { + checkNotFinished() + if (discardingContent) return false + beginChild(tagName, validate) + return true + } + + /** Only called by compiler plugin. Appends compiler-validated attribute syntax. */ + @PublishedApi + internal fun writeMixedLiteral(encoded: String) { + output.append(encoded) + } + + /** Only called by compiler plugin. Escapes and writes a dynamic attribute value. */ + @PublishedApi + internal fun writeMixedAttribute(prefix: String, value: String?, boolean: Boolean) { + if (value == null) return + output.append(prefix) + if (!boolean || value.isNotEmpty()) { + output.append("=\"") + output.appendEscapedAttribute(value) + output.append('"') + } + } + + /** Only called by compiler plugin. Writes collected inline styles. */ + @PublishedApi + internal fun writeMixedStyle(scope: StyleScopeBuilder?) { + writeMixedAttribute(" style", scope?.toStyleAttributeValue(), false) + } + + /** Only called by compiler plugin. Applies strict class-token checks. */ + @PublishedApi + internal fun validateMixedClass(value: String?, validate: Boolean, duplicate: Boolean) { + if (!validate || value == null) return + require(!duplicate) { "Class token must not be repeated: \"$value\"" } + require(value.isNotEmpty()) { "Class token must not be empty" } + require(value.none { it in AsciiWhitespaceCharacters }) { + "Class token must not contain ASCII whitespace: \"$value\"" + } + } + + /** Only called by compiler plugin. Appends one class token. */ + @PublishedApi + internal fun writeMixedClass(value: String?, previous: Boolean, encoded: Boolean): Boolean { + if (value == null) return previous + output.append(if (previous) " " else " class=\"") + if (encoded) output.append(value) else output.appendEscapedAttribute(value) + return true + } + + /** Only called by compiler plugin. Closes a class attribute if any tokens were written. */ + @PublishedApi + internal fun finishMixedClasses(written: Boolean) { + if (written) output.append('"') + } + + /** Only called by compiler plugin. Enters the element after its attributes have been written. */ + @PublishedApi + internal fun finishMixedElement( + tagName: String, + namespace: String = HtmlNamespace, + leadingNewline: Boolean = false, + ): Any? { + output.append('>') + if (isHtmlVoidElement(tagName, namespace)) { + previousSiblingWasText = false + pendingLeadingNewline = false + flushChunk() + return null + } + val parent = enter(tagName, namespace, leadingNewline) + flushChunk() + return parent + } + + /** Only called by compiler plugin. */ + @PublishedApi + internal fun closeElement(encoded: String, parent: Any?) { + checkNotFinished() + if (discardingContent) return + output.append(encoded) + exit(parent) + flushChunk() + } + + internal fun beginChild(tagName: String, validate: Boolean) { + require(!collectingRawText) { "String rendering does not support element children inside <$currentTagName>" } + if (validate && currentNamespace == HtmlNamespace) requireHtmlParserStableTableElement(currentTagName, tagName) + if (currentTagName == null) recordRootChild(isHtml = tagName == "html") + flushChunk() + } + + internal fun enter(tagName: String, namespace: String, leadingNewline: Boolean): Any? { + // The identity check keeps String.equals out of compiled region openings so the JVM can inline them. + val parent = if (currentNamespace === HtmlNamespace || currentNamespace == HtmlNamespace) currentTagName + else ForeignParent(currentTagName, currentNamespace) + if (currentTagName !== tagName) currentTagName = tagName + if (currentNamespace !== namespace) currentNamespace = namespace + previousSiblingWasText = false + pendingLeadingNewline = leadingNewline + return parent + } + + internal fun exit(parent: Any?) { + val tagName = if (parent is ForeignParent) parent.tagName else parent as String? + val namespace = if (parent is ForeignParent) parent.namespace else HtmlNamespace + if (currentTagName !== tagName) currentTagName = tagName + if (currentNamespace !== namespace) currentNamespace = namespace + previousSiblingWasText = false + pendingLeadingNewline = false // This element's start tag consumed its parent's first content. + } + + // Ordinary HTML parents stay as tag names, with no allocation on the common path. + internal class ForeignParent(val tagName: String?, val namespace: String) + + /** Used by compiler-generated calls and ordinary Text rendering. */ + @PublishedApi + internal fun writeText(value: String, validate: Boolean) { checkNotFinished() when { discardingContent -> Unit collectingRawText -> { - // Even an empty text call must go through raw-text validation at the end tag. + // Empty Text calls still require validation at the end tag. hasRawTextCall = true rawText.append(value) } value.isNotEmpty() -> { - writeText(value, LocalHtmlValidationMode.current == HtmlValidationMode.Strict) + appendText(value, validate) flushChunk() } } } + @Composable + @NonRestartableComposable + @ExplicitGroupsComposable + override fun <TElement : Element> RawTextElement( + tagName: String, + applyAttrs: (AttrsScope<TElement>.() -> Unit)?, + content: RawTextContent, + ) { + TagElement(elementBuilder<TElement>(tagName), applyAttrs, rawTextContent = content, content = null) + } + + @Composable + @NonRestartableComposable + @ExplicitGroupsComposable + override fun TextElement(value: String) { + writeText(value, LocalHtmlValidationMode.current == HtmlValidationMode.Strict) + } + @Composable @NonRestartableComposable @ExplicitGroupsComposable @@ -198,8 +433,8 @@ internal class HtmlStringWriterContext( } attrsBuilders.trim(attrsBuilderIndex) if (!streaming) { - peakBufferSize = output.length - return output.toString() + peakBufferSize = immutableOutput?.length ?: output.length + return immutableOutput ?: output.toString() } flushChunk(force = true) @@ -214,12 +449,14 @@ internal class HtmlStringWriterContext( tagNameIsValid: Boolean, ): StartTagResult { if (validate && !tagNameIsValid) requireValidHtmlTagName(tagName) - require(!collectingRawText) { - "String rendering does not support element children inside <$currentTagName>" - } - if (currentTagName == null) recordRootChild(isHtml = tagName == "html") - if (validate && currentNamespace == HtmlNamespace) requireHtmlParserStableTableElement(currentTagName, tagName) + beginChild(tagName, validate) + val prepared = if (namespace == HtmlNamespace) preparedHtmlCallbackMetadata(applyAttrs) else null + if (prepared != null && prepared.tagName == tagName) { + output.append(prepared.startTag) + pendingLeadingNewline = false + return StartTagResult.Ordinary + } val builder = attrsBuilders.builder<TElement>(attrsBuilderIndex++) try { applyAttrs?.invoke(builder) @@ -243,12 +480,7 @@ internal class HtmlStringWriterContext( } } - private fun writeEndTag( - tagName: String, - namespace: String, - hasScriptSource: Boolean, - contentStart: Int, - ) { + private fun writeEndTag(tagName: String, namespace: String, hasScriptSource: Boolean, contentStart: Int) { if (hasRawTextCall) { val content = RawTextContent.create(tagName, rawText.toString()) content.validateScriptSource(hasScriptSource) @@ -259,7 +491,7 @@ internal class HtmlStringWriterContext( output.appendEndTag(tagName, namespace, contentStart) } - private fun writeText(value: String, validate: Boolean) { + internal fun appendText(value: String, validate: Boolean) { if (currentTagName == null) recordRootChild(isHtml = false) if (validate && currentNamespace == HtmlNamespace) requireHtmlParserStableTableText(currentTagName, value) if (pendingLeadingNewline) { @@ -275,7 +507,8 @@ internal class HtmlStringWriterContext( } /** Emits the shared buffer at a safe boundary once it reaches the target size. */ - private fun flushChunk(force: Boolean = false) { + @PublishedApi + internal fun flushChunk(force: Boolean = false) { val chunkSink = chunkSink ?: return // A buffered element validates its content when its end tag is written. if (bufferedElementDepth != 0 || output.isEmpty() || (!force && output.length < chunkSize)) return @@ -285,12 +518,16 @@ internal class HtmlStringWriterContext( output.setLength(0) } - private fun recordRootChild(isHtml: Boolean) { + internal fun recordRootChild(isHtml: Boolean) { if (rootChildCount++ == 0) firstRootChildIsHtml = isHtml } - private fun checkNotFinished() { + internal fun checkNotFinished() { check(!finished) { "String rendering does not support HTML emission after composition" } + immutableOutput?.let { + output.append(it) + immutableOutput = null + } } private fun <TElement : Element> elementScope(): ElementScope<TElement> = elementScope.unsafeCast() @@ -300,6 +537,7 @@ internal class HtmlStringWriterContext( internal class AttrsBuilderPool { val nameCache = HtmlAttributeNameCache() private val builders = mutableListOf<AttrsScopeBuilder<*>>() + // Reuse immutable metadata for SVG elements and keyed renders. Bound both the number // and length of retained dynamic names. Only one active renderer owns this storage. private val elementBuilders = HashMap<String, MutableMap<String, StringElementBuilder<*>>>() @@ -325,3 +563,8 @@ internal class AttrsBuilderPool { if (size < builders.size) builders.subList(size, builders.size).clear() } } + +/** Only called by compiler plugin. */ +@PublishedApi +internal fun mixedStyle(scope: StyleScopeBuilder?, block: StyleScope.() -> Unit): StyleScopeBuilder = + (scope ?: StyleScopeBuilder()).apply(block) diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/InputElements.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/InputElements.kt index faab24a519..f49fa92560 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/InputElements.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/InputElements.kt @@ -3,6 +3,8 @@ * 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 @@ -27,6 +29,7 @@ private fun InputAttrsScope<String>.applyAttrsWithStringValue( */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "checkbox", defaults = ["checked=false"]) fun CheckboxInput(checked: Boolean = false, attrs: InputAttrsScope<Boolean>.() -> Unit = {}) { Input( type = InputType.Checkbox, @@ -46,6 +49,7 @@ fun CheckboxInput(checked: Boolean = false, attrs: InputAttrsScope<Boolean>.() - */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "date", defaults = ["value="]) fun DateInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Date, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -59,6 +63,7 @@ fun DateInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {} */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "datetime-local", defaults = ["value="]) fun DateTimeLocalInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.DateTimeLocal, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -72,6 +77,7 @@ fun DateTimeLocalInput(value: String = "", attrs: InputAttrsScope<String>.() -> */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "email", defaults = ["value="]) fun EmailInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Email, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -85,6 +91,7 @@ fun EmailInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = { */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "file", defaults = ["value="]) fun FileInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.File, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -98,6 +105,7 @@ fun FileInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {} */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "hidden") fun HiddenInput(attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Hidden, attrs = attrs) } @@ -111,6 +119,7 @@ fun HiddenInput(attrs: InputAttrsScope<String>.() -> Unit = {}) { */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "month", defaults = ["value="]) fun MonthInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Month, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -124,6 +133,7 @@ fun MonthInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = { */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "number") fun NumberInput( value: Number? = null, min: Number? = null, @@ -150,6 +160,7 @@ fun NumberInput( */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "password", defaults = ["value="]) fun PasswordInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Password, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -163,6 +174,7 @@ fun PasswordInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "radio", defaults = ["checked=false"]) fun RadioInput(checked: Boolean = false, attrs: InputAttrsScope<Boolean>.() -> Unit = {}) { Input( type = InputType.Radio, @@ -182,6 +194,7 @@ fun RadioInput(checked: Boolean = false, attrs: InputAttrsScope<Boolean>.() -> U */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "range", defaults = ["step=1"]) fun RangeInput( value: Number? = null, min: Number? = null, @@ -210,6 +223,7 @@ fun RangeInput( */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "search", defaults = ["value="]) fun SearchInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Search, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -220,6 +234,7 @@ fun SearchInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "submit") fun SubmitInput(attrs: InputAttrsScope<Unit>.() -> Unit = {}) { Input(type = InputType.Submit, attrs = attrs) } @@ -233,6 +248,7 @@ fun SubmitInput(attrs: InputAttrsScope<Unit>.() -> Unit = {}) { */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "tel", defaults = ["value="]) fun TelInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Tel, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -246,6 +262,7 @@ fun TelInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "text", defaults = ["value="]) fun TextInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Text, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -259,6 +276,7 @@ fun TextInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {} */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "time", defaults = ["value="]) fun TimeInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Time, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -272,6 +290,7 @@ fun TimeInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {} */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "url", defaults = ["value="]) fun UrlInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Url, attrs = { applyAttrsWithStringValue(value, attrs) }) } @@ -285,6 +304,7 @@ fun UrlInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) */ @Composable @NonRestartableComposable +@LowerableHtmlTag("input", inputType = "week", defaults = ["value="]) fun WeekInput(value: String = "", attrs: InputAttrsScope<String>.() -> Unit = {}) { Input(type = InputType.Week, attrs = { applyAttrsWithStringValue(value, attrs) }) } diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/LowerableHtmlTag.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/LowerableHtmlTag.kt new file mode 100644 index 0000000000..1b11552203 --- /dev/null +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/LowerableHtmlTag.kt @@ -0,0 +1,52 @@ +/* + * 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.web.dom + +import org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi +import org.jetbrains.compose.web.attributes.InputType + +/** + * Internal compiler contract. Attribute helpers follow the attrs receiver and parameter type. + * Only defaults and behavior not expressed by the signature need metadata. + */ +@ComposeWebInternalApi +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +annotation class LowerableHtmlTag( + val tagName: String = "", + val namespace: String = HtmlNamespace, + // Non-null library defaults, encoded as "parameter=literal". + val defaults: Array<String> = [], + val afterAttrs: Array<String> = [], + val text: String = "", + val omitEmpty: Array<String> = [], + val inputType: String = "", +) + +/** A direct attribute helper. Names and toString conversions follow the signature by default. */ +@ComposeWebInternalApi +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +annotation class LowerableAttribute( + val name: String = "", + val conversion: String = "", + val value: String = "", + // Serialized argument default; an empty array requires an explicit argument. + val defaultValue: Array<String> = [], +) + +@PublishedApi +internal fun htmlAutoCompleteValue(value: Boolean): String = if (value) "on" else "off" + +@PublishedApi +internal fun inputValueIsAttribute(type: InputType<*>): Boolean = + type == InputType.Checkbox || + type == InputType.Radio || + type == InputType.Hidden || + type == InputType.Submit diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StaticHtml.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StaticHtml.kt new file mode 100644 index 0000000000..a42233a07c --- /dev/null +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StaticHtml.kt @@ -0,0 +1,156 @@ +/* + * 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.compose.web.internal.runtime.ComposeWebInternalApi::class) + +// Access shared serialization helpers without exposing them as public API. +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package org.jetbrains.compose.web.dom + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import kotlinx.browser.dom.Element +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.internal.runtime.ComposeWebInternalApi + +// Runtime support for generated code and tests. + +/** Stores prepared HTML attributes. Created by [preparedHtmlAttributes]. */ +@ComposeWebInternalApi +class PreparedHtmlAttributes<TElement : Element> +internal constructor( + internal val tagName: String, + private val frozen: AttrsScopeBuilder<TElement>, + internal val startTag: String, +) { + internal val elementBuilder = createPlatformElementBuilder<TElement>(tagName) + val callback: AttrsScope<TElement>.() -> Unit = createPreparedHtmlCallback(this) + + internal fun applyTo(target: AttrsScope<TElement>) { + frozen.collect().forEach { (name, value) -> target.attr(name, value) } + target.classes(frozen.classes) + } +} + +internal expect fun <TElement : Element> createPreparedHtmlCallback( + attributes: PreparedHtmlAttributes<TElement> +): AttrsScope<TElement>.() -> Unit + +internal expect fun <TElement : Element> preparedHtmlCallbackMetadata( + callback: (AttrsScope<TElement>.() -> Unit)? +): PreparedHtmlAttributes<TElement>? + +/** + * Only called by compiler plugin. + * Builds and validates reusable attributes for generated code. + * ``` + * private val attributes = preparedHtmlAttributes<HTMLDivElement>( + * "div", "<div class=\"card\">", + * ) { + * classes("card") + * } + * + * fun Page(name: String) { + * PreparedContainer(attributes) { + * Text(name) + * } + * } + * ``` + */ +@ComposeWebInternalApi +fun <TElement : Element> preparedHtmlAttributes( + tagName: String, + expectedStartTag: String, + initialize: AttrsScope<TElement>.() -> Unit, +): PreparedHtmlAttributes<TElement> { + requireValidHtmlTagName(tagName) + require(supportsPreparedHtmlAttributes(tagName)) + val attrs = AttrsScopeBuilder<TElement>().apply(initialize) + require( + attrs.propertyUpdatesOrEmpty.isEmpty() && + attrs.styleScopeOrNull == null && + attrs.refEffect == null && + attrs.hydrationProtocolAttributes.isEmpty() && + !attrs.allowsHydrationMismatch + ) + val encoded = + StringBuilder() + .apply { appendStartTag(tagName, HtmlNamespace, attrs, true, null, null) } + .toString() + require(encoded == expectedStartTag) { "Invalid prepared HTML attributes" } + return PreparedHtmlAttributes(tagName, attrs, expectedStartTag) +} + +/** Only called by compiler plugin. Evaluates dynamic text inside the prepared element's scope. */ +@ComposeWebInternalApi +@Composable +@NonRestartableComposable +@androidx.compose.runtime.ExplicitGroupsComposable +fun <TElement : Element> PreparedTextElement( + attributes: PreparedHtmlAttributes<TElement>, + value: @Composable ElementScope<TElement>.() -> String, +) { + LocalComposeHtmlContext.current.DynamicTextTagElement(attributes, value) +} + +/** + * Only called by compiler plugin. + * Writes compiler-validated HTML directly to the current output buffer. + * Inlining puts the StringBuilder append in the caller. + */ +@ComposeWebInternalApi +@Composable +@NonRestartableComposable +@androidx.compose.runtime.ExplicitGroupsComposable +@Suppress("NOTHING_TO_INLINE") // Put the append in generated callers, without a callback allocation. +inline fun WriteStaticHtml(rootTag: String, encoded: String) { + val writer = currentHtmlStringWriter() + val output = + writer.beginStaticHtml(rootTag, encoded, LocalHtmlValidationMode.current == HtmlValidationMode.Strict) ?: return + output.append(encoded) + writer.flushChunk() +} + +/** Used by compiler-generated calls and inline [WriteStaticHtml]. */ +@PublishedApi +@Composable +@NonRestartableComposable +@androidx.compose.runtime.ExplicitGroupsComposable +internal fun currentHtmlStringWriter(): HtmlStringWriterContext = + LocalComposeHtmlContext.current as? HtmlStringWriterContext + ?: error("Direct HTML writes require HtmlStringWriterContext; this rendering context does not support them") + +/** Only called by compiler plugin. */ +@PublishedApi +@Composable +@NonRestartableComposable +@androidx.compose.runtime.ExplicitGroupsComposable +@Suppress("NOTHING_TO_INLINE") +internal inline fun currentHtmlValidationIsStrict(): Boolean = + LocalHtmlValidationMode.current == HtmlValidationMode.Strict + +/** + * Only called by compiler plugin. + * Renders a prepared element while composing its children normally. + */ +@ComposeWebInternalApi +@Composable +@NonRestartableComposable +@androidx.compose.runtime.ExplicitGroupsComposable +fun <TElement : Element> PreparedContainer( + attributes: PreparedHtmlAttributes<TElement>, + content: @Composable ElementScope<TElement>.() -> Unit, +) { + LocalComposeHtmlContext.current.TagElement( + attributes.elementBuilder, + attributes.callback, + null, + content, + ) +} 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 c43ab0852a..1d4e84bdfc 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 @@ -3,6 +3,9 @@ * 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.web.dom import androidx.compose.runtime.Composable @@ -72,7 +75,8 @@ internal object StringComposeHtmlContext : ComposeHtmlContext { update = { val attrsScope = AttrsScopeBuilder<TElement>() applyAttrs?.invoke(attrsScope) - val formState = attrsScope.propertyUpdatesOrEmpty.formControlState(elementBuilder.tagName, namespace) + val formState = + attrsScope.propertyUpdatesOrEmpty.formControlState(elementBuilder.tagName, namespace) val attributes = attrsScope.stringAttributes(namespace, validate, formState) rawText?.validateAttributes(attributes.byName) diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringElementBuilder.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringElementBuilder.kt index 675992357c..6316555e3b 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringElementBuilder.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringElementBuilder.kt @@ -3,6 +3,9 @@ * 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.web.dom import kotlinx.browser.dom.Element @@ -32,6 +35,6 @@ internal const val ValidTagFlag = 8 internal fun htmlStringTagFlags(tagName: String, namespace: String): Int = (if (isHtmlRawTextElement(tagName, namespace)) RawTextTagFlag else 0) or - (if (isHtmlVoidElement(tagName, namespace)) VoidTagFlag else 0) or - (if (isHtmlLeadingNewlineElement(tagName, namespace)) LeadingNewlineTagFlag else 0) or - (if (isValidHtmlTagName(tagName)) ValidTagFlag else 0) + (if (isHtmlVoidElement(tagName, namespace)) VoidTagFlag else 0) or + (if (isHtmlLeadingNewlineElement(tagName, namespace)) LeadingNewlineTagFlag else 0) or + (if (isValidHtmlTagName(tagName)) ValidTagFlag else 0) diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringFormControlProperties.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringFormControlProperties.kt index 2990d26602..7480dfaf4c 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringFormControlProperties.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/StringFormControlProperties.kt @@ -3,6 +3,9 @@ * 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.web.dom import kotlinx.browser.dom.Element @@ -50,6 +53,7 @@ internal fun List<Pair<(Element, Any) -> Unit, Any>>.formControlState( } if (inputValue == null && checked == null) null else FormControlState(inputValue, checked) } + "textarea" -> { var textAreaValue: String? = null var textAreaDefaultValue: String? = null @@ -62,6 +66,7 @@ internal fun List<Pair<(Element, Any) -> Unit, Any>>.formControlState( // A controlled value takes precedence over a default, including an explicitly empty value. (textAreaValue ?: textAreaDefaultValue)?.let { FormControlState(textContent = it) } } + else -> null } } 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 e49e3e3422..79302759f4 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 @@ -3,6 +3,9 @@ * 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.web.dom // in-memory equivalent of DOM node diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectElementWriterTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectElementWriterTest.kt new file mode 100644 index 0000000000..661bcc5540 --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectElementWriterTest.kt @@ -0,0 +1,177 @@ +/* + * 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.compose.web.internal.runtime.ComposeWebInternalApi::class) + +package org.jetbrains.compose.web.dom + +import androidx.compose.runtime.Composable +import kotlinx.browser.dom.Element +import kotlinx.browser.dom.HTMLTextAreaElement +import org.jetbrains.compose.web.attributes.setTextAreaValue +import org.jetbrains.compose.web.composeHtmlToStream +import org.jetbrains.compose.web.composeHtmlToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class DirectElementWriterTest { + @Test + fun chunkedFragmentsValidateAndCountOnceAndRejectWritesAfterFinishing() { + val writer = HtmlStringWriterContext(hydratable = true) + assertEquals(true, writer.beginStaticHtmlFragment("html", true)) + writer.appendStaticHtmlChunk("<html><body>") + writer.appendStaticHtmlChunk("content</body></html>") + assertEquals("<html><body>content</body></html>", writer.finish(requireHtmlDocumentRoot = true)) + assertFailsWith<IllegalStateException> { writer.beginStaticHtmlFragment("div", true) } + + val invalidParent = HtmlStringWriterContext(hydratable = true) + invalidParent.openLoweredRegion("table", "<table>", "table", true) + assertFailsWith<IllegalArgumentException> { invalidParent.beginStaticHtmlFragment("div", true) } + } + + @Test + fun bridgeMatchesOrdinaryElementsInEveryRenderingMode() { + for (strict in listOf(false, true)) { + for (hydratable in listOf(false, true)) { + for (stream in listOf(false, true)) { + for (value in listOf("", "<&>\ré😀", "\ntext")) { + val ordinary: @Composable () -> Unit = { + Text("before") + Div(attrs = { + id("greeting") + attr("title", "Hello & goodbye") + classes("card") + }) { + Text(value) + Text("") + Text("tail") + Span { Text("child") } + } + Text("after") + Text("adjacent") + } + val direct: @Composable () -> Unit = { + Text("before") + val writer = currentHtmlStringWriter() + val parent = writer.openLoweredRegion("div", StartTag, "div", strict) + writer.writeText(value, strict) + writer.writeText("", strict) + writer.writeText("tail", strict) + Span { Text("child") } + writer.closeElement("</div>", parent) + Text("after") + Text("adjacent") + } + assertEquals( + render(strict, hydratable, stream, ordinary), + render(strict, hydratable, stream, direct) + ) + } + } + } + } + } + + @Test + fun discardedContentStillEvaluatesText() { + var calls = 0 + val discarded: @Composable () -> Unit = { + val writer = currentHtmlStringWriter() + val parent = writer.openLoweredRegion("div", StartTag, "div", true) + writer.writeText(run { + calls++ + "bad\u0000" + }, true) + writer.closeElement("</div>", parent) + } + assertEquals("<input><textarea>override</textarea><span>after</span>", composeHtmlToString { + TagElement<Element>("input", null) { discarded() } + TagElement<HTMLTextAreaElement>("textarea", { prop(setTextAreaValue, "override") }) { + discarded() + } + Span { Text("after") } + }) + assertEquals(2, calls) + } + + @Test + fun invalidParentsRejectElementsBeforeEvaluatingText() { + for (stream in listOf(false, true)) { + for (raw in listOf(false, true)) { + var calls = 0 + assertFailsWith<IllegalArgumentException> { + render(true, true, stream) { + TagElement<Element>(if (raw) "iframe" else "table", null) { + val writer = currentHtmlStringWriter() + val parent = writer.openLoweredRegion("div", StartTag, "div", true) + writer.writeText(run { + calls++ + "text" + }, true) + writer.closeElement("</div>", parent) + } + } + } + assertEquals(0, calls) + } + } + } + + @Test + fun restoresForeignNamespaceAndLeadingNewlineState() { + assertEquals( + "<svg><div></div><table><div></div></table></svg><pre><div></div>\ntext</pre>", + composeHtmlToString(validateStrictly = true) { + TagElementNS<Element>("svg", "urn:test", null) { + EmptyDirectDiv() + TagElementNS<Element>("table", "urn:test", null) { + EmptyDirectDiv() + } + } + Pre { + EmptyDirectDiv() + Text("\ntext") + } + }) + } + + @Test + fun countsDocumentRootsAndRejectsWritesAfterFinishing() { + val writer = HtmlStringWriterContext(hydratable = true) + val parent = writer.openLoweredRegion("html", "<html>", "html", true) + writer.closeElement("</html>", parent) + assertEquals("<html></html>", writer.finish(requireHtmlDocumentRoot = true)) + assertFailsWith<IllegalStateException> { writer.writeText("late", true) } + + val multipleRoots = HtmlStringWriterContext(hydratable = true) + repeat(2) { + val root = multipleRoots.openLoweredRegion("html", "<html>", "html", true) + multipleRoots.closeElement("</html>", root) + } + assertFailsWith<IllegalArgumentException> { multipleRoots.finish(requireHtmlDocumentRoot = true) } + } + + private fun render(strict: Boolean, hydratable: Boolean, stream: Boolean, content: @Composable () -> Unit): String { + if (!stream) return composeHtmlToString(validateStrictly = strict, hydratable = hydratable, content = content) + val output = StringBuilder() + composeHtmlToStream( + validateStrictly = strict, hydratable = hydratable, chunkSize = 1, + sink = { output.append(it) }, content = content + ) + return output.toString() + } + + @Composable + private fun EmptyDirectDiv() { + val writer = currentHtmlStringWriter() + val parent = writer.openLoweredRegion("div", "<div>", "div", true) + writer.closeElement("</div>", parent) + } + + private companion object { + const val StartTag = "<div id=\"greeting\" title=\"Hello & goodbye\" class=\"card\">" + } +} diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt index 68739880e4..a04eff0102 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt @@ -52,10 +52,16 @@ class DirectStringRenderingTest { TagElement<Element>("textarea", null) { Text("\nfirst"); Text("second") } TagElement<Element>("script", null) { Text("const x = '<&';") } TagElement<Element>("noscript", null) { Span { Text("fallback") } } - TagElementNS<Element>("svg", "http://www.w3.org/2000/svg", { attr("viewBox", "0 0 1 1") }, null) + TagElementNS<Element>( + "svg", + "http://www.w3.org/2000/svg", + { attr("viewBox", "0 0 1 1") }, + null + ) } } - val expected = composeHtmlToString(hydratable = hydratable, validateStrictly = strict, content = content) + val expected = + composeHtmlToString(hydratable = hydratable, validateStrictly = strict, content = content) assertEquals(expected, composeHtmlToString(hydratable, "direct-changing", strict, content)) } } @@ -67,7 +73,10 @@ class DirectStringRenderingTest { var value = "" var decorated = true val content: @Composable () -> Unit = { - Div(attrs = if (decorated) { { id("decorated") } } else null) { Text(value) } + Div( + attrs = if (decorated) { + { id("decorated") } + } else null) { Text(value) } } for ((index, text) in listOf("Unicode é😀 &<>".repeat(10_000), "short", "", "again").withIndex()) { value = text @@ -114,7 +123,10 @@ class DirectStringRenderingTest { val movable = movableContentOf { Span { Text("moved") } } val content: @Composable () -> Unit = { Div { movable() }; P { Text("after") } } assertEquals("<div><span>moved</span></div><p>after</p>", composeHtmlToString(content = content)) - assertEquals("<div><span>moved</span></div><p>after</p>", composeHtmlToString(key = "movable", content = content)) + assertEquals( + "<div><span>moved</span></div><p>after</p>", + composeHtmlToString(key = "movable", content = content) + ) } @Test @@ -125,9 +137,20 @@ class DirectStringRenderingTest { { TagElement<Element>("script", null) { Text("</scr"); Text("ipt>") } }, { TagElement<Element>("style", null) { Span { Text("child") } } }, ) - for (content in invalid) { - assertFailsWith<IllegalArgumentException> { composeHtmlToString(content = content) } - assertFailsWith<IllegalArgumentException> { composeHtmlToString(key = "direct-failure", content = content) } + for (strict in listOf(false, true)) for (content in invalid) { + assertFailsWith<IllegalArgumentException> { + composeHtmlToString( + validateStrictly = strict, + content = content + ) + } + assertFailsWith<IllegalArgumentException> { + composeHtmlToString( + key = "direct-failure", + validateStrictly = strict, + content = content + ) + } assertEquals("<div>ok</div>", composeHtmlToString(key = "direct-failure") { Div { Text("ok") } }) } } @@ -148,7 +171,8 @@ class DirectStringRenderingTest { @Test fun nestedRenderRestoresContextWithTheSameOrDifferentKey() { for (innerKey in listOf("inner", "outer")) { - assertEquals("<div>outer<!--c--><span>inner</span><!--c-->tail</div>", + assertEquals( + "<div>outer<!--c--><span>inner</span><!--c-->tail</div>", composeHtmlToString(key = "outer") { Div { Text("outer") diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlNamespaceEscapingTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlNamespaceEscapingTest.kt index c4bb8659e5..3782c68577 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlNamespaceEscapingTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlNamespaceEscapingTest.kt @@ -2,6 +2,9 @@ * 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.web.dom import kotlinx.browser.dom.Element @@ -35,9 +38,11 @@ class HtmlNamespaceEscapingTest { repeat(8) { index -> val namespace = if (index % 2 == 0) HtmlNamespace else "http://www.w3.org/2000/svg" val tag = if (index % 2 == 0) "div" else "svg" - assertEquals("<$tag title=\"$index&\">$index<</$tag>", composeHtmlToString(key = "mixed-namespace-escaping", validateStrictly = true) { - TagElementNS<Element>(tag, namespace, { attr("title", "$index&") }) { Text("$index<") } - }) + assertEquals( + "<$tag title=\"$index&\">$index<</$tag>", + composeHtmlToString(key = "mixed-namespace-escaping", validateStrictly = true) { + TagElementNS<Element>(tag, namespace, { attr("title", "$index&") }) { Text("$index<") } + }) } } diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlSerializationTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlSerializationTest.kt index 5dfa0a90e5..5e9c9a4750 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlSerializationTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/HtmlSerializationTest.kt @@ -3,6 +3,9 @@ * 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.web.dom import androidx.compose.runtime.Composable @@ -96,6 +99,12 @@ class HtmlSerializationTest { else -> "outside the table" } assertContains(failure.message.orEmpty(), suggestion) + val childHtml = if (child == "col") "<col>" else "<$child></$child>" + assertEquals( + "<$parent>$childHtml</$parent>", + composeHtmlToString(hydratable, validateStrictly = false) { + TagElement<Element>(parent, null) { TagElement<Element>(child, null, null) } + }) } } } @@ -345,7 +354,7 @@ class HtmlSerializationTest { assertEquals( "<title>A & B < C" + - "
<script>alert(1)</script>
", + "
<script>alert(1)</script>
", html, ) } @@ -458,8 +467,8 @@ class HtmlSerializationTest { assertEquals( "
" + - "
" + - "
", + "
" + + "
", html, ) } @@ -483,7 +492,7 @@ class HtmlSerializationTest { assertEquals( "
" + - "
", + "
", html, ) } @@ -512,11 +521,13 @@ class HtmlSerializationTest { fun acceptsAdjustedSvgNamesAndLowercaseCustomNames() { assertEquals( "" + - "", + "", composeHtmlToString { TagElementNS("svg", TestSvgNamespace, { attr("viewBox", "0 0 10 10") }) { - TagElementNS("linearGradient", TestSvgNamespace, - { attr("gradientUnits", "userSpaceOnUse") }, null) + TagElementNS( + "linearGradient", TestSvgNamespace, + { attr("gradientUnits", "userSpaceOnUse") }, null + ) TagElementNS("sparkline", TestSvgNamespace, { attr("datapoints", "0,1") attr("xlink:href", "#line") @@ -618,9 +629,9 @@ class HtmlSerializationTest { assertEquals( "" + - "" + - "" + - "", + "" + + "" + + "", html, ) } @@ -706,10 +717,10 @@ class HtmlSerializationTest { assertEquals( "" + - "
foreignObject
" + - "
title
" + - "
desc
" + - "
", + "
foreignObject
" + + "<div>title</div>" + + "
desc
" + + "", html, ) } @@ -767,15 +778,25 @@ class HtmlSerializationTest { html, ) } + @Test fun attributeNameChecksRejectAllControlsAndProtocolSpoofing() { for (character in (0..31).map(Int::toChar) + (127..159).map(Int::toChar) + " \"'/>=".toList()) { assertFailsWith { - composeHtmlToString(validateStrictly = true) { Div(attrs = { attr("prefix${character}suffix", "value") }) } + composeHtmlToString(validateStrictly = true) { + Div(attrs = { + attr( + "prefix${character}suffix", + "value" + ) + }) + } } } - for (name in listOf("data-compose-hydration-root", "DATA-COMPOSE-HYDRATION-STATE", - "Data-Compose-Hydration-For", "data-compose-hydration-validation")) { + for (name in listOf( + "data-compose-hydration-root", "DATA-COMPOSE-HYDRATION-STATE", + "Data-Compose-Hydration-For", "data-compose-hydration-validation" + )) { assertFailsWith { composeHtmlToString { Div(attrs = { attr(name, "value") }) } } @@ -788,16 +809,20 @@ class HtmlSerializationTest { @Test fun pooledAttributeNamesNeverCacheValues() { repeat(6) { i -> - assertEquals("
", composeHtmlToString(key = "attribute-name-reuse") { - Div({ attr("data-long-name-for-validation", "$i&\"") }) - }) + assertEquals( + "
", + composeHtmlToString(key = "attribute-name-reuse") { + Div({ attr("data-long-name-for-validation", "$i&\"") }) + }) } assertFailsWith { composeHtmlToString(key = "attribute-name-reuse", validateStrictly = true) { Div({ attr("invalid name", "value") }) } } - assertEquals("
", composeHtmlToString(key = "attribute-name-reuse") { Div({ title("next") }) }) + assertEquals( + "
", + composeHtmlToString(key = "attribute-name-reuse") { Div({ title("next") }) }) } @Test @@ -815,11 +840,13 @@ class HtmlSerializationTest { repeat(2) { assertFailsWith { composeHtmlToString(key = "foreign-attribute-reuse", validateStrictly = true) { - TagElementNS("g", "http://www.w3.org/2000/svg", + TagElementNS( + "g", "http://www.w3.org/2000/svg", { attr("viewBox", "first") attr("viewbox", "second") - }, null) + }, null + ) } } } @@ -835,12 +862,33 @@ class HtmlSerializationTest { } assertEquals( "
" + - "${prefix}<&> \"\n\t&amp;
", + "${prefix}<&> \"\n\t&amp;
", html, ) } } + @Test + fun escapingChecksLongSuffixesAfterAmpersands() { + for (length in listOf(31, 32, 33, 1024)) { + val plain = "é😀a".repeat(length) + val value = "<&" + plain + "&\"\n\t" + plain + "<\r>" + assertEquals( + "
" + + "<&${plain}&\"\n\t${plain}< >
", + composeHtmlToString { + Div(attrs = { attr("title", value) }) { Text(value) } + }, + ) + assertEquals("<&$plain", composeHtmlToString { Text("<&$plain") }) + val invalid = "&$plain\u0000" + assertFailsWith { composeHtmlToString { Text(invalid) } } + assertFailsWith { + composeHtmlToString { Div(attrs = { attr("title", invalid) }) } + } + } + } + @Test fun longUnescapedStringsStillRejectNul() { for (position in listOf(0, 31, 32, 1000)) { diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/LoweredRegionWriterTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/LoweredRegionWriterTest.kt new file mode 100644 index 0000000000..15a9a4660e --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/LoweredRegionWriterTest.kt @@ -0,0 +1,113 @@ +/* + * 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.compose.web.internal.runtime.ComposeWebInternalApi::class) + +package org.jetbrains.compose.web.dom + +import androidx.compose.runtime.Composable +import kotlinx.browser.dom.Element +import org.jetbrains.compose.web.composeHtmlToStream +import org.jetbrains.compose.web.composeHtmlToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class LoweredRegionWriterTest { + @Test + fun transitionsMatchOrdinaryElements() { + for (strict in listOf(false, true)) for (hydratable in listOf(false, true)) for (stream in listOf( + false, + true + )) { + val ordinary: @Composable () -> Unit = { + Div { + H1 { Text("Title") } + P { + Text("a") + Text("b") + } + Span { Text("c") } + Text("d") + } + Text("after") + } + val lowered: @Composable () -> Unit = { + val writer = currentHtmlStringWriter() + val parent = writer.openLoweredRegion("div", "

Title

", "p", strict) + assertEquals("p", writer.currentTagName) + Text("a") + Text("b") + writer.advanceLoweredRegion("

", "span") + assertEquals("span", writer.currentTagName) + Text("c") + writer.advanceLoweredRegion("", "div") + Text("d") + writer.closeElement("
", parent) + assertNull(writer.currentTagName) + Text("after") + } + assertEquals(render(strict, hydratable, stream, ordinary), render(strict, hydratable, stream, lowered)) + } + } + + @Test + fun discardedRegionsStillRunDynamicEffects() { + var effects = 0 + assertEquals("after", composeHtmlToString { + TagElement("input", null) { + val writer = currentHtmlStringWriter() + val parent = writer.openLoweredRegion("div", "

", "p", true) + effects++ + writer.writeText("discarded", true) + writer.advanceLoweredRegion("

", "span") + writer.closeElement("
", parent) + } + Span { Text("after") } + }) + assertEquals(1, effects) + } + + @Test + fun entryFailurePreservesPrecedingOutputAndSkipsDynamicEffect() { + val output = StringBuilder() + var effects = 0 + assertFailsWith { + composeHtmlToStream(validateStrictly = true, chunkSize = 1, sink = { output.append(it) }) { + TagElement("table", null) { + currentHtmlStringWriter().openLoweredRegion("div", "

", "p", true) + effects++ + } + } + } + assertEquals("", output.toString()) + assertEquals(0, effects) + } + + @Test + fun restoresForeignNamespaceAndRejectsFinishedWriter() { + val writer = HtmlStringWriterContext(hydratable = true) + val outer = writer.enter("svg", "urn:test", false) + val parent = writer.openLoweredRegion("div", "

", "p", true) + writer.closeElement("

", parent) + assertEquals("svg", writer.currentTagName) + assertEquals("urn:test", writer.currentNamespace) + writer.exit(outer) + assertEquals("

", writer.finish(requireHtmlDocumentRoot = false)) + assertFailsWith { writer.openLoweredRegion("div", "
", "div", true) } + assertFailsWith { writer.advanceLoweredRegion("

", "div") } + } + + private fun render(strict: Boolean, hydratable: Boolean, stream: Boolean, content: @Composable () -> Unit): String { + if (!stream) return composeHtmlToString(validateStrictly = strict, hydratable = hydratable, content = content) + val output = StringBuilder() + composeHtmlToStream( + validateStrictly = strict, hydratable = hydratable, chunkSize = 1, + sink = { output.append(it) }, content = content, + ) + return output.toString() + } +} diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/LoweredSubtreeTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/LoweredSubtreeTest.kt new file mode 100644 index 0000000000..2dbcdcabe9 --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/LoweredSubtreeTest.kt @@ -0,0 +1,232 @@ +/* + * 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.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.compositionLocalOf +import kotlinx.browser.dom.HTMLElement +import org.jetbrains.compose.web.HtmlValidationMode +import org.jetbrains.compose.web.LocalHtmlValidationMode +import org.jetbrains.compose.web.composeHtmlString +import org.jetbrains.compose.web.composeHtmlToStream +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.renderHydratedDocument +import org.jetbrains.compose.web.renderHydratedDocumentToStream +import kotlin.test.* + +class LoweredSubtreeTest { + @Test + fun specializedContainersKeepParserChecksAndNewlines() { + val pre = preparedHtmlAttributes("pre", "
") {}
+        assertEquals("
\n\nfirstsecond
", composeHtmlToString { + PreparedContainer(pre) { Text("\nfirst"); Text("second") } + }) + assertFailsWith { + composeHtmlToString(validateStrictly = true) { Table { PreparedContainer(attrs) {} } } + } + assertFailsWith { + composeHtmlToString { TagElement("script", null) { PreparedContainer(attrs) {} } } + } + var calls = 0 + assertEquals("", composeHtmlToString { + TagElement("input", null) { PreparedContainer(attrs) { calls++; Text("discarded") } } + }) + assertEquals(1, calls) + } + + private val attrs = preparedHtmlAttributes("span", "") { classes("x") } + + @Test + fun loweredContainersPreserveContentEffectsAndTextBoundaries() { + val trace = mutableListOf() + val content: @Composable ElementScope.() -> Unit = { + androidx.compose.runtime.remember { trace += "remember"; Unit } + androidx.compose.runtime.SideEffect { trace += "side" } + androidx.compose.runtime.DisposableEffect(Unit) { trace += "enter"; onDispose { trace += "dispose" } } + Text("a"); Text("b"); Span { Text("nested") } + } + val ordinary = composeHtmlToString { TagElement(attrs.elementBuilder, attrs.callback, content) } + val ordinaryTrace = trace.toList() + trace.clear() + val lowered = composeHtmlToString { PreparedContainer(attrs, content) } + assertEquals(ordinary, lowered) + assertEquals(ordinaryTrace, trace) + } + + @Test + fun dynamicValuesKeepScopeAndCompositionLocals() { + val local = compositionLocalOf { "default" } + for (value in listOf("<&é😀", "", "next", "\n")) { + assertEquals( + "" + value.replace("&", "&").replace("<", "<") + "", + composeHtmlToString(key = "lowered-local") { + CompositionLocalProvider(local provides value) { + PreparedTextElement(attrs) { local.current } + } + }) + } + } + + @Test + fun textEvaluationAndFailuresKeepStreamingOrder() { + fun render(lowered: Boolean): Pair> { + val chunks = StringBuilder() + val events = mutableListOf() + assertFailsWith { + composeHtmlString(chunkSize = 1, chunkSink = { chunks.append(it); events += "chunk:$it" }) { + if (lowered) PreparedTextElement(attrs) { events += "value"; error("failed value") } + else Span(attrs = { classes("x") }) { Text(run { events += "value"; error("failed value") }) } + } + } + return chunks.toString() to events + } + assertEquals(render(false), render(true)) + assertEquals("recovered", composeHtmlToString(key = "lowered-local") { + PreparedTextElement(attrs) { "recovered" } + }) + } + + @Test + fun discardedTextStillEvaluatesAndInvalidTextStillFails() { + var calls = 0 + composeHtmlToString { + TagElement( + "input", + null + ) { PreparedTextElement(attrs) { calls++; "discarded" } } + } + assertEquals(1, calls) + assertFailsWith { composeHtmlToString { PreparedTextElement(attrs) { "bad\u0000" } } } + } + + @Test + fun staticSubtreeMatchesOrdinaryAndStreamingOutput() { + val original: @Composable () -> Unit = { + Div { + Span { + Text("<&") + Text("") + Text("tail") + } + P { Text("é😀") } + } + } + val html = "
<&tail

é😀

" + assertEquals(html, composeHtmlToString(content = original)) + val write: @Composable () -> Unit = { WriteStaticHtml("div", html) } + for (hydratable in listOf(false, true)) { + val boundary = if (hydratable) "" else "" + val expected = "before${html}after${boundary}next" + assertEquals(expected, composeHtmlToString(hydratable = hydratable) { + Text("before"); write(); Text("after"); Text("next") + }) + val chunks = mutableListOf() + composeHtmlToStream(hydratable = hydratable, chunkSize = 1, sink = { chunks.add(it) }) { + Text("before"); write(); Text("after"); Text("next") + } + assertEquals(expected, chunks.joinToString("")) + assertEquals(listOf("before", html, "after", "${boundary}next"), chunks) + } + } + + @Test + fun immutableOutputCanBeReturnedOrExtendedAndReusedUnderOneKey() { + val html = "
constant
" + val write: @Composable () -> Unit = { WriteStaticHtml("div", html) } + for (hydratable in listOf(false, true)) { + fun render(content: @Composable () -> Unit) = + composeHtmlToString(key = "immutable-output", hydratable = hydratable, content = content) + assertSame(html, render { write() }) + assertEquals(html + "tail", render { write(); Text("tail") }) + assertEquals("head" + html, render { Text("head"); write() }) + assertEquals(html + html, render { write(); write() }) + assertEquals("
head${html}tail
", render { + Section { Text("head"); write(); Text("tail") } + }) + assertSame(html, render { write() }) + } + } + + @Test + fun staticWritesCheckParentsAndDiscardVoidContent() { + val write: @Composable () -> Unit = { WriteStaticHtml("div", "
safe
") } + assertFailsWith { composeHtmlToString(validateStrictly = true) { Table { write() } } } + for (tag in listOf("script", "iframe")) { + assertFailsWith { + composeHtmlToString { + TagElement( + tag, + null + ) { write() } + } + } + } + assertEquals("", composeHtmlToString { TagElement("input", null) { write() } }) + } + + @Test + fun staticWritesUseTheCurrentValidationMode() { + val write: @Composable () -> Unit = { WriteStaticHtml("tr", "
") } + for (strict in listOf(false, true)) for (stream in listOf(false, true)) { + // Override the outer setting to verify that static writes read the active provider. + val content: @Composable () -> Unit = { + CompositionLocalProvider(LocalHtmlValidationMode provides if (strict) HtmlValidationMode.Strict else HtmlValidationMode.Fast) { + Table { write() } + } + } + + fun render(): String { + if (!stream) return composeHtmlToString(validateStrictly = !strict, content = content) + val output = StringBuilder() + composeHtmlToStream( + sink = { output.append(it) }, + chunkSize = 1, + validateStrictly = !strict, + content = content + ) + return output.toString() + } + if (strict) assertFailsWith { render() } + else assertEquals("
cell
cell
", render()) + } + } + + @Test + fun staticWritesRetainDocumentRootMetadata() { + val document = "" + val write: @Composable () -> Unit = { WriteStaticHtml("html", document) } + assertSame(document, composeHtmlString(requireHtmlDocumentRoot = true) { write() }) + val chunks = StringBuilder() + composeHtmlString(requireHtmlDocumentRoot = true, chunkSink = { chunks.append(it) }, chunkSize = 1) { write() } + assertEquals(document, chunks.toString()) + val invalidRoots = listOf( + Triple("div", "

", 1), + Triple("html", document, 2), + ) + for ((tag, html, count) in invalidRoots) { + val content: @Composable () -> Unit = { repeat(count) { WriteStaticHtml(tag, html) } } + assertFailsWith { + composeHtmlString(requireHtmlDocumentRoot = true, content = content) + } + for (strict in listOf(false, true)) for (stream in listOf(false, true)) { + fun render(): String { + if (!stream) return renderHydratedDocument(validateStrictly = strict, content = content) + val output = StringBuilder() + renderHydratedDocumentToStream( + sink = { output.append(it) }, chunkSize = 1, + validateStrictly = strict, content = content + ) + return output.toString() + } + if (strict) assertFailsWith { render() } + else assertEquals("" + composeHtmlToString(content = content), render()) + } + } + } +} diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/PreparedHtmlAttributesTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/PreparedHtmlAttributesTest.kt new file mode 100644 index 0000000000..3546e0f5c3 --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/PreparedHtmlAttributesTest.kt @@ -0,0 +1,144 @@ +/* + * 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.compose.web.internal.runtime.ComposeWebInternalApi::class) + +package org.jetbrains.compose.web.dom + +import androidx.compose.runtime.Composable +import kotlinx.browser.dom.HTMLElement +import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.composeHtmlString +import org.jetbrains.compose.web.composeHtmlToStream +import kotlin.test.* + +class PreparedHtmlAttributesTest { + @Test + fun preparedTextPreservesLeadingNewlinesAndParentTextBoundaries() { + for (tag in listOf("pre", "textarea", "listing", "title")) { + val attributes = preparedHtmlAttributes(tag, "<$tag>") {} + for (value in listOf("", "\n<&é😀", "text")) { + for (strict in listOf(false, true)) { + for (hydratable in listOf(false, true)) { + for (stream in listOf(false, true)) { + fun render(content: @Composable () -> Unit): String { + if (!stream) return composeHtmlToString( + validateStrictly = strict, hydratable = hydratable, content = content, + ) + val output = StringBuilder() + composeHtmlToStream( + validateStrictly = strict, hydratable = hydratable, chunkSize = 1, + sink = { output.append(it) }, content = content, + ) + return output.toString() + } + + val expected = render { + Pre { + TagElement(tag, null) { Text(value) } + Text("\ntail") + Text("adjacent") + } + } + assertEquals(expected, render { + Pre { + PreparedTextElement(attributes) { value } + Text("\ntail") + Text("adjacent") + } + }, "$tag, value=$value, strict=$strict, hydratable=$hydratable, stream=$stream") + } + } + } + } + } + } + + @Test + fun preparedAttributesAndTextMatchOrdinaryRendering() { + val attrs = preparedHtmlAttributes("div", "
") { + classes( + "a&", + "b" + ); id("x&") + } + val text = "é😀<&>" + for (strict in listOf(false, true)) { + assertEquals( + "
é😀<&>
", + composeHtmlToString(validateStrictly = strict) { PreparedTextElement(attrs) { text } }) + } + } + + @Test + fun changingTextUnderOneKeyDoesNotReuseEarlierValues() { + val attrs = preparedHtmlAttributes("div", "
") { classes("stable") } + for (value in listOf("first", "<&second", "", "é😀")) { + assertEquals( + composeHtmlToString { Div(attrs = { classes("stable") }) { Text(value) } }, + composeHtmlToString(key = "static-changing-text") { PreparedTextElement(attrs) { value } }) + } + } + + @Test + fun incorrectEncodedDataAndUnsupportedTagsAreRejected() { + assertFailsWith { preparedHtmlAttributes("div", "
") {} } + for (tag in listOf("bad tag", "script", "style", "iframe", "area", "meta", "noscript")) { + assertFailsWith(tag) { + preparedHtmlAttributes(tag, "<$tag>") {} + } + } + } + + @Test + fun aDifferentTagUsesTheOrdinaryAttributeCallback() { + val attrs = preparedHtmlAttributes("div", "
") { id("x") } + assertEquals("value", composeHtmlToString { + TagElement("span", attrs.callback) { Text("value") } + }) + } + + @Test + fun parserChecksAndFailureRecoveryStillApplyToStaticLeaves() { + val div = preparedHtmlAttributes("div", "
") {} + val tr = preparedHtmlAttributes("tr", "") {} + assertFailsWith { + composeHtmlToString(key = "static-parser-recovery", validateStrictly = true) { + Table { + PreparedTextElement( + div + ) { "invalid child" } + } + } + } + assertFailsWith { + composeHtmlToString(validateStrictly = true) { Table { Tbody { PreparedTextElement(tr) { "text" } } } } + } + assertEquals("
invalid child
", composeHtmlToString(validateStrictly = false) { + Table { PreparedTextElement(div) { "invalid child" } } + }) + assertEquals("text
", composeHtmlToString(validateStrictly = false) { + Table { Tbody { PreparedTextElement(tr) { "text" } } } + }) + assertEquals("
recovered
", composeHtmlToString(key = "static-parser-recovery") { + PreparedTextElement(div) { "recovered" } + }) + assertFailsWith { composeHtmlToString { PreparedTextElement(div) { "bad\u0000" } } } + } + + @Test + fun streamingAndTextBoundariesMatchTheOrdinaryPath() { + val attrs = preparedHtmlAttributes("div", "
") { classes("x") } + val text = "\n<&é😀" + val expected = composeHtmlToString { + Text("before"); Div(attrs = { classes("x") }) { Text("\n<&é😀") }; Text("after"); Text("adjacent") + } + val chunks = StringBuilder() + val remainder = composeHtmlString(chunkSize = 1, chunkSink = { chunks.append(it) }) { + Text("before"); PreparedTextElement(attrs) { text }; Text("after"); Text("adjacent") + } + assertEquals(expected, chunks.append(remainder).toString()) + } +} diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/RawTextScannerTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/RawTextScannerTest.kt index cf459f4aea..2905be247f 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/RawTextScannerTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/RawTextScannerTest.kt @@ -3,6 +3,9 @@ * 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.web.dom import kotlin.test.Test diff --git a/html/eap/core/src/jsMain/kotlin/org/jetbrains/compose/web/dom/AttributeEquals.kt b/html/eap/core/src/jsMain/kotlin/org/jetbrains/compose/web/dom/AttributeEquals.kt index 208f7363e5..d8531fa0a8 100644 --- a/html/eap/core/src/jsMain/kotlin/org/jetbrains/compose/web/dom/AttributeEquals.kt +++ b/html/eap/core/src/jsMain/kotlin/org/jetbrains/compose/web/dom/AttributeEquals.kt @@ -3,6 +3,9 @@ * 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.web.dom import kotlinx.browser.dom.Element diff --git a/html/eap/core/src/jsMain/kotlin/org/jetbrains/compose/web/dom/DomChecks.kt b/html/eap/core/src/jsMain/kotlin/org/jetbrains/compose/web/dom/DomChecks.kt index 8a99e4b781..7f493093d5 100644 --- a/html/eap/core/src/jsMain/kotlin/org/jetbrains/compose/web/dom/DomChecks.kt +++ b/html/eap/core/src/jsMain/kotlin/org/jetbrains/compose/web/dom/DomChecks.kt @@ -3,6 +3,9 @@ * 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.web.dom import kotlinx.browser.dom.Element @@ -13,6 +16,7 @@ import kotlinx.browser.dom.Text internal actual fun Element.hasHtmlNamespace(): Boolean = namespaceURI == HtmlNamespace internal actual fun Element.matchesElement(localName: String, namespace: String): Boolean = this.localName == localName && namespaceURI == namespace + internal actual fun Text.matchesText(value: String): Boolean = data == value internal actual fun Node.isRawTextContainer(): Boolean = this is Element && isHtmlRawTextElement(localName, namespaceURI) diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt index ad58596f3b..bd889a18d2 100644 --- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt @@ -15,7 +15,8 @@ import java.io.Writer * for each chunk. The writer is not flushed or closed. The writer must consume each write before returning. * * [chunkSize] is a target minimum in UTF-16 code units. Tags and individual `Text` calls stay - * intact. Elements requiring content validation are emitted only after validation succeeds. + * intact on the runtime path. Compiler-validated fragments can stream in smaller pieces. + * Elements requiring content validation are emitted only after validation succeeds. * A write can exceed [chunkSize]. No empty writes are made. * * Rendering uses the caller's current snapshot. Use a mutable snapshot around rendering for isolation. diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Elements.jvm.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Elements.jvm.kt index 9cae7c0c04..a98aa2c6ec 100644 --- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Elements.jvm.kt +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/Elements.jvm.kt @@ -3,6 +3,9 @@ * 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.web.dom import kotlinx.browser.dom.Element diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.jvm.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.jvm.kt index 17e34dd044..ef2d0891a7 100644 --- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.jvm.kt +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerialization.jvm.kt @@ -9,13 +9,19 @@ package org.jetbrains.compose.web.dom import org.jetbrains.compose.web.internal.unsafeCast -internal actual fun canAppendUnescapedHtmlString(value: String, attribute: Boolean): Boolean { - if (value.length < 32) return false +internal actual fun canAppendUnescapedHtmlString(value: String, attribute: Boolean, startIndex: Int): Boolean { + if (value.length - startIndex < 32) return false // Use JVM String searches to skip Kotlin's search wrappers. val text = value.unsafeCast() - return text.indexOf('&'.code) < 0 && text.indexOf('<'.code) < 0 && text.indexOf('>'.code) < 0 && - text.indexOf('\r'.code) < 0 && text.indexOf('\u0000'.code) < 0 && - (!attribute || (text.indexOf('"'.code) < 0 && text.indexOf('\n'.code) < 0 && text.indexOf('\t'.code) < 0)) + return text.indexOf('&'.code, startIndex) < 0 && text.indexOf('<'.code, startIndex) < 0 && text.indexOf( + '>'.code, + startIndex + ) < 0 && + text.indexOf('\r'.code, startIndex) < 0 && text.indexOf('\u0000'.code, startIndex) < 0 && + (!attribute || (text.indexOf('"'.code, startIndex) < 0 && text.indexOf( + '\n'.code, + startIndex + ) < 0 && text.indexOf('\t'.code, startIndex) < 0)) } internal actual fun StringBuilder.appendHtmlRun(value: String, start: Int, end: Int) { @@ -53,6 +59,7 @@ internal actual fun StringBuilder.appendEscapedHtmlString(value: String, attribu if (attribute) "HTML attribute values must not contain NUL (U+0000)" else "HTML text must not contain NUL (U+0000)" ) + '&' -> "&" '<' -> "<" '>' -> ">" @@ -64,19 +71,9 @@ internal actual fun StringBuilder.appendEscapedHtmlString(value: String, attribu appendHtmlRun(value, start, index) append(replacement) start = index + 1 - if (character == '&' && canAppendUnescapedHtmlSuffix(value, attribute, start)) { + if (character == '&' && canAppendUnescapedHtmlString(value, attribute, start)) { appendHtmlRun(value, start, value.length) return true } } } - -private fun canAppendUnescapedHtmlSuffix(value: String, attribute: Boolean, start: Int): Boolean { - if (value.length - start < 32) return false - val text = value.unsafeCast() - return text.indexOf('&'.code, start) < 0 && text.indexOf('<'.code, start) < 0 && - text.indexOf('>'.code, start) < 0 && text.indexOf('\r'.code, start) < 0 && - text.indexOf('\u0000'.code, start) < 0 && - (!attribute || (text.indexOf('"'.code, start) < 0 && text.indexOf('\n'.code, start) < 0 && - text.indexOf('\t'.code, start) < 0)) -} diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/PreparedHtmlCallback.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/PreparedHtmlCallback.kt new file mode 100644 index 0000000000..9ac1f79d0e --- /dev/null +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/dom/PreparedHtmlCallback.kt @@ -0,0 +1,26 @@ +/* + * 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.compose.web.internal.runtime.ComposeWebInternalApi::class) + +package org.jetbrains.compose.web.dom + +import kotlinx.browser.dom.Element +import org.jetbrains.compose.web.attributes.AttrsScope + +// Keep the start tag in the callback so SSR can write it directly. +private class JvmPreparedHtmlCallback( + val attributes: PreparedHtmlAttributes, +) : Function1, Unit> { + override fun invoke(target: AttrsScope) = attributes.applyTo(target) +} + +internal actual fun createPreparedHtmlCallback( + attributes: PreparedHtmlAttributes, +): AttrsScope.() -> Unit = JvmPreparedHtmlCallback(attributes) + +internal actual fun preparedHtmlCallbackMetadata( + callback: (AttrsScope.() -> Unit)?, +): PreparedHtmlAttributes? = (callback as? JvmPreparedHtmlCallback)?.attributes diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerializationJvmTest.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerializationJvmTest.kt new file mode 100644 index 0000000000..dc0f32e539 --- /dev/null +++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/HtmlStringSerializationJvmTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web.dom + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.assertFailsWith +import kotlin.random.Random + +class HtmlStringSerializationJvmTest { + @Test + fun specializedSerializerMatchesEscapingForEveryCodeUnit() { + val padding = "a".repeat(128) + for (code in Char.MIN_VALUE.code..Char.MAX_VALUE.code) { + for (offset in 0..3) { + val value = padding + "a".repeat(offset) + code.toChar() + padding + for (attribute in listOf(false, true)) { + val output = StringBuilder("prefix:") + if (code == 0) { + assertFailsWith { output.appendEscapedHtmlString(value, attribute) } + } else { + assertTrue(output.appendEscapedHtmlString(value, attribute)) + assertEquals( + "prefix:" + referenceEscape(value, attribute), + output.toString(), + "Code unit $code" + ) + } + } + } + } + } + + @Test + fun serializersMatchForVariedEscapePositionsAndDensities() { + val random = Random(342625) + val alphabet = "abc f|~@MF éα日本語😀&<>\r\"\n\t" + for (length in listOf(0, 31, 32, 64, 127, 128, 129, 255, 256, 257, 1024, 8192)) { + repeat(16) { + val value = buildString { repeat(length) { append(alphabet[random.nextInt(alphabet.length)]) } } + assertEquals(referenceEscape(value, false), buildString { appendEscapedText(value) }) + assertEquals(referenceEscape(value, true), buildString { appendEscapedAttribute(value) }) + } + } + } + + private fun referenceEscape(value: String, attribute: Boolean): String { + var result = value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\r", " ") + if (attribute) result = result.replace("\"", """).replace("\n", " ").replace("\t", " ") + return result + } + + @Test + fun fastPathChecksEveryUtf16CodeUnitInTextAndAttributes() { + for (paddingLength in listOf(32, 128)) { + val padding = "a".repeat(paddingLength) + for (code in Char.MIN_VALUE.code..Char.MAX_VALUE.code) { + val character = code.toChar() + val textSafe = character !in "\u0000&<>\r" + val attributeSafe = textSafe && character !in "\"\n\t" + for (offset in 0..3) { + val value = padding + "a".repeat(offset) + character + padding + assertEquals( + textSafe, + canAppendUnescapedHtmlString(value, attribute = false), + "Text code unit $code at $offset", + ) + assertEquals( + attributeSafe, + canAppendUnescapedHtmlString(value, attribute = true), + "Attribute code unit $code at $offset", + ) + } + } + } + } + + @Test + fun fastPathChecksOnlyTheRequestedSuffixAndKeepsTheShortRunCutoff() { + for (attribute in listOf(false, true)) { + for (length in listOf(0, 1, 30, 31, 32, 33, 64, 127, 128, 129, 1024)) { + val value = "\u0000&<>\r\"\n\t" + "a".repeat(length) + assertEquals(length >= 32, canAppendUnescapedHtmlString(value, attribute, startIndex = 8)) + } + for (character in "\u0000&<>\r" + if (attribute) "\"\n\t" else "") { + for (length in (32..35) + (128..131)) { + assertFalse(canAppendUnescapedHtmlString("a".repeat(length - 1) + character, attribute)) + } + for (position in listOf(0, 31, 32, 64, 127, 128, 129)) { + val value = "&" + "a".repeat(position) + character + "a".repeat(64) + assertFalse(canAppendUnescapedHtmlString(value, attribute, startIndex = 1)) + assertTrue(canAppendUnescapedHtmlString(value, attribute, startIndex = position + 2)) + } + } + } + } +} diff --git a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/Base.web.kt b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/Base.web.kt index 6c4e88e458..17924be877 100644 --- a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/Base.web.kt +++ b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/dom/Base.web.kt @@ -5,6 +5,9 @@ @file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) +// Access shared serialization helpers without exposing them as public API. +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package org.jetbrains.compose.web.dom import androidx.compose.runtime.* @@ -65,11 +68,11 @@ private class ElementAttrs(val builder: AttrsScopeBuilder<*>) { val a = builder val b = other.builder return a.collect() == b.collect() && - a.classes == b.classes && - a.styleScopeOrNull == b.styleScopeOrNull && - a.propertyUpdatesOrEmpty == b.propertyUpdatesOrEmpty && - a.eventsListenerScopeBuilder.collectListeners() == - b.eventsListenerScopeBuilder.collectListeners() + a.classes == b.classes && + a.styleScopeOrNull == b.styleScopeOrNull && + a.propertyUpdatesOrEmpty == b.propertyUpdatesOrEmpty && + a.eventsListenerScopeBuilder.collectListeners() == + b.eventsListenerScopeBuilder.collectListeners() } override fun hashCode(): Int = builder.collect().hashCode() @@ -336,7 +339,7 @@ private class HydratingDomElementWrapper( } applier.mismatch( "attribute \"$name\": expected ${expected.describeAttributeValue()}, " + - "found ${actual.describeAttributeValue()}", + "found ${actual.describeAttributeValue()}", ) } } @@ -346,6 +349,7 @@ private fun Element.composedAttributeNamespace(name: String): String? = if (namespaceURI == HtmlNamespace) null else when (name) { "xlink:actuate", "xlink:arcrole", "xlink:href", "xlink:role", "xlink:show", "xlink:title", "xlink:type" -> "http://www.w3.org/1999/xlink" + "xml:base", "xml:lang", "xml:space" -> "http://www.w3.org/XML/1998/namespace" "xmlns", "xmlns:xlink" -> "http://www.w3.org/2000/xmlns/" else -> null @@ -601,7 +605,7 @@ private class HydratingComposeHtmlContext( // Keep scripted