From 373ba8c5178508fab28a825709ef7359e948197c Mon Sep 17 00:00:00 2001 From: Clemens Koprolin Date: Thu, 8 Oct 2026 11:42:05 +0200 Subject: [PATCH 1/3] Make Compose HTML renderer reuse safe for concurrent calls Pool renderers by key --- html/eap/benchmarks/README.md | 33 ++ html/eap/benchmarks/build.gradle.kts | 28 ++ .../compose/web/RendererPoolBenchmark.java | 94 +++++ .../compose/web/ComposeHtmlToStream.kt | 33 +- .../compose/web/ComposeHtmlToString.kt | 94 +++-- .../jetbrains/compose/web/HtmlRendererPool.kt | 28 ++ .../jetbrains/compose/web/HydratedDocument.kt | 70 +++- .../web/dom/HtmlStringSerialization.kt | 3 +- .../web/dom/HtmlStringWriterContext.kt | 28 +- .../compose/web/HtmlRendererPoolTest.kt | 169 ++++++++ .../compose/web/HydratedDocumentPoolTest.kt | 184 ++++++++ .../compose/web/SinglePassRenderingTest.kt | 33 +- .../web/dom/ComposeHtmlToStreamTest.kt | 54 +++ .../web/dom/DirectStringRenderingTest.kt | 25 +- .../compose/web/ComposeHtmlToStream.kt | 26 +- .../compose/web/HtmlRendererPool.jvm.kt | 63 +++ .../jetbrains/compose/web/HydratedDocument.kt | 23 +- .../compose/web/ComposeHtmlConcurrencyTest.kt | 392 ++++++++++++++++++ .../web/HtmlRendererPoolRetentionTest.kt | 94 +++++ .../web/HydratedDocumentToWriterTest.kt | 23 +- .../web/dom/ComposeHtmlToWriterTest.kt | 23 +- .../compose/web/HtmlRendererPool.web.kt | 31 ++ html/settings.gradle.kts | 1 + 23 files changed, 1413 insertions(+), 139 deletions(-) create mode 100644 html/eap/benchmarks/README.md create mode 100644 html/eap/benchmarks/build.gradle.kts create mode 100644 html/eap/benchmarks/src/main/java/org/jetbrains/compose/web/RendererPoolBenchmark.java create mode 100644 html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.kt create mode 100644 html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HtmlRendererPoolTest.kt create mode 100644 html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentPoolTest.kt create mode 100644 html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.jvm.kt create mode 100644 html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/ComposeHtmlConcurrencyTest.kt create mode 100644 html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HtmlRendererPoolRetentionTest.kt create mode 100644 html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.web.kt diff --git a/html/eap/benchmarks/README.md b/html/eap/benchmarks/README.md new file mode 100644 index 0000000000..f09cb240b0 --- /dev/null +++ b/html/eap/benchmarks/README.md @@ -0,0 +1,33 @@ +# EAP benchmarks + +Compare three ways to render HTML: + +- `fresh`: create a renderer for each call. +- `threadLocal`: reuse a renderer on each worker thread. +- `sharedKeyedPool`: reuse the production pool with one key shared by all workers. + +Each case checks its HTML output before measurement. + +## Run + +From the repository root, run (default 4 workers): + +```shell +html/gradlew -p html -Pcompose.html.eap.enabled=true \ + :html-eap-benchmarks:run +``` + +For a single-worker baseline: + +```shell +html/gradlew -p html -Pcompose.html.eap.enabled=true \ + :html-eap-benchmarks:run --args='-prof gc -t 1' +``` + +Defaults: 1 or 64 rows, two JVM forks, two one-second warmups, and three one-second measurements. +GC profiling reports allocation. + +## Add a benchmark + +Add a Java class under `src/main/java` with JMH `@Benchmark` methods. +Gradle discovers it automatically. diff --git a/html/eap/benchmarks/build.gradle.kts b/html/eap/benchmarks/build.gradle.kts new file mode 100644 index 0000000000..f33294ee74 --- /dev/null +++ b/html/eap/benchmarks/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + application +} + +val composeVersion = providers.gradleProperty("compose.version").get() +val browserVersion = providers.gradleProperty("compose.html.eap.kotlinx-browser-common-subset.version").get() +val jmhVersion = "1.37" + +dependencies { + implementation(project(":html-core-eap")) + implementation("org.jetbrains.compose.runtime:runtime:$composeVersion") + implementation("org.jetbrains.compose.html:kotlinx-browser-common-subset:$browserVersion") + implementation("org.openjdk.jmh:jmh-core:$jmhVersion") + annotationProcessor("org.openjdk.jmh:jmh-generator-annprocess:$jmhVersion") +} + +// Match the library's Java compatibility. +tasks.withType().configureEach { + options.release.set(11) +} + +application { + mainClass.set("org.openjdk.jmh.Main") +} + +tasks.named("run") { + args("-prof", "gc") +} diff --git a/html/eap/benchmarks/src/main/java/org/jetbrains/compose/web/RendererPoolBenchmark.java b/html/eap/benchmarks/src/main/java/org/jetbrains/compose/web/RendererPoolBenchmark.java new file mode 100644 index 0000000000..24b8d04b8f --- /dev/null +++ b/html/eap/benchmarks/src/main/java/org/jetbrains/compose/web/RendererPoolBenchmark.java @@ -0,0 +1,94 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web; + +import androidx.compose.runtime.Composer; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import kotlin.Unit; +import kotlin.jvm.functions.Function2; +import org.jetbrains.compose.web.dom.ElementsKt; +import org.openjdk.jmh.annotations.*; + +/** Compares renderer reuse strategies with the same HTML workload on concurrent workers. */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 2, time = 1) +@Measurement(iterations = 3, time = 1) +@Fork(value = 2, jvmArgsAppend = {"-Xms256m", "-Xmx256m"}) +@Threads(4) +public class RendererPoolBenchmark { + @Param({"1", "64"}) + public int rows; + + // Keep the previous cache shape for a direct before/after comparison. + private final ThreadLocal> threadLocal = + ThreadLocal.withInitial(HashMap::new); + private Function2 content; + + @Setup + public void setup() { + content = (composer, changed) -> { + for (int row = 0; row < rows; row++) { + ElementsKt.Div(null, (scope, childComposer, childChanged) -> { + ElementsKt.Text("row <&>\uD83D\uDE00", childComposer, 0); + ElementsKt.Span(null, (childScope, textComposer, textChanged) -> { + ElementsKt.Text("tail", textComposer, 0); + return Unit.INSTANCE; + }, childComposer, 0, 1); + return Unit.INSTANCE; + }, composer, 0, 1); + } + return Unit.INSTANCE; + }; + // Check identical output for every strategy before measuring performance. + String expected = "
row <&>\uD83D\uDE00tail
".repeat(rows); + check(expected, fresh()); + check(expected, threadLocal()); + check(expected, sharedKeyedPool()); + } + + // Allocation baseline: each call owns a new renderer. + @Benchmark + public String fresh() { + return render(new HtmlRenderer()); + } + + // Each worker reuses its own renderer. + @Benchmark + public String threadLocal() { + Map cache = threadLocal.get(); + HtmlRenderer renderer = cache.get("template"); + if (renderer == null) { + renderer = new HtmlRenderer(); + cache.put("template", renderer); + } + return render(renderer); + } + + // Workers borrow exclusive renderers from the same keyed pool. + @Benchmark + public String sharedKeyedPool() { + HtmlRendererPool pool = ComposeHtmlToStringKt.getHtmlRendererPool("benchmark-template"); + HtmlRenderer renderer = pool.borrow(); + String result = render(renderer); + pool.recycle(renderer); + return result; + } + + private String render(HtmlRenderer renderer) { + // Use the same rendering path in every case, excluding public validation wrappers. + return renderer.render(true, false, null, 2048, "", content); + } + + private static void check(String expected, String actual) { + if (!expected.equals(actual)) { + throw new AssertionError("Every storage strategy must render the expected HTML"); + } + } +} 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 551b7765f2..054b27d743 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 @@ -6,30 +6,30 @@ package org.jetbrains.compose.web import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider /** * Composes [content] once and sends HTML chunks synchronously to [sink]. Concatenating the chunks * 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. Leading-newline repair - * for `pre`, `textarea`, and `listing` happens before their first content is emitted. - * A chunk can exceed [chunkSize], and no empty chunks are sent. + * calls stay intact, as do elements whose contents require validation. A chunk can exceed [chunkSize], + * and no empty chunks are sent. * - * Rendering behavior and limitations follow [composeHtmlToString]. - * Each render has fresh remembered state; a non-null [key] reuses string and streaming storage. - * Keyed calls are not thread-safe, even with different keys. Snapshot state changes are discarded. - * Coroutine effects such as `LaunchedEffect` do not run, while `SideEffect` and `DisposableEffect` still execute. + * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. * * [sink] runs synchronously while rendering and may block. Output already sent cannot be retracted - * if rendering or the sink throws. Failed rendering storage is reset before the exception is rethrown. + * if rendering or the sink throws. Failed rendering storage is discarded. * + * @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. + * @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. - * @throws IllegalArgumentException if [chunkSize] is not positive or content cannot be serialized. - * @throws IllegalStateException if rendering reenters with the same non-null [key], or [content] - * accesses `currentRecomposeScope`. + * @param content the composable content to render once. + * @throws IllegalArgumentException if [chunkSize] is not positive or [content] cannot be safely serialized as HTML. + * @throws IllegalStateException if [content] accesses `currentRecomposeScope`. * @throws UnsupportedOperationException if [content] uses an unsupported composition operation. */ fun composeHtmlToStream( @@ -40,14 +40,9 @@ fun composeHtmlToStream( validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, ) { - require(chunkSize > 0) { "chunkSize must be positive" } val chunkSink: (StringBuilder) -> Unit = { sink(it.toString()) } - val validatedContent: @Composable () -> Unit = { - CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) { - content() - } - } composeHtmlString( - hydratable = hydratable, key = key, chunkSink = chunkSink, chunkSize = chunkSize, content = validatedContent, + hydratable = hydratable, key = key, chunkSink = chunkSink, chunkSize = chunkSize, + content = htmlValidatedContent(validateStrictly, content), ) } 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 01514bb425..24a4c48ae9 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 @@ -17,28 +17,26 @@ import org.jetbrains.compose.web.dom.LocalComposeHtmlContext /** * Composes [content] once into an HTML string without creating browser DOM nodes. - * With no [key], rendering storage is discarded after the HTML has been written. - * With a key, storage is retained for later string and streaming calls. - * Keyed calls are not thread-safe, even with different keys. - * Snapshot state changes made while rendering are discarded afterwards. - * Coroutine effects such as `LaunchedEffect` do not run. `SideEffect` and - * `DisposableEffect` still execute. + * + * Snapshot state changes are discarded after rendering. Coroutine effects such as + * `LaunchedEffect` do not run. `SideEffect` and `DisposableEffect` still execute. + * + * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * See [composeHtmlToStream] for validation and failure handling. * * Inline styles preserve CSS fallbacks but do not fully emulate CSSOM validation and mutation. * Custom callbacks passed to `AttrsScope.prop(...)` are ignored during string rendering. * - * On the JVM or Node, set `COMPOSE_HTML_VALIDATE_STRICTLY=true` to enable additional class and - * duplicate foreign-attribute checks by default. + * On the JVM or Node.js, set `COMPOSE_HTML_VALIDATE_STRICTLY=true` to enable additional class and + * duplicate foreign-attribute checks. * - * @param hydratable whether to emit text-boundary markers required to hydrate adjacent `Text` + * @param hydratable whether to emit text-boundary markers needed to hydrate adjacent `Text` * nodes. Set to `false` when the output will not be hydrated. - * @param key when non-null, reuses rendering storage across calls. + * @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. - * @throws IllegalArgumentException if a raw-text element contains unsafe text or element children, - * serialized `noscript` contents contain a `` end tag, or ordinary text, RCDATA, - * or attribute values contain NUL (U+0000), which HTML parsing cannot preserve. - * @throws IllegalStateException if rendering reenters with the same non-null [key], or [content] - * accesses `currentRecomposeScope`. + * @throws IllegalArgumentException if [content] cannot be safely serialized as HTML. + * @throws IllegalStateException if [content] accesses `currentRecomposeScope`. * @throws UnsupportedOperationException if [content] uses an unsupported composition operation. */ fun composeHtmlToString( @@ -47,12 +45,7 @@ fun composeHtmlToString( validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, ): String { - val validatedContent: @Composable () -> Unit = { - CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) { - content() - } - } - return composeHtmlString(hydratable = hydratable, key = key, content = validatedContent) + return composeHtmlString(hydratable = hydratable, key = key, content = htmlValidatedContent(validateStrictly, content)) } /** Shared single-pass composition and writer for fragments, documents, and streaming output. */ @@ -65,22 +58,29 @@ internal fun composeHtmlString( key: String? = null, content: @Composable () -> Unit, ): String { - val renderer = if (key == null) HtmlRenderer(reusable = false) else renderers.getOrPut(key) { HtmlRenderer() } - return renderer.render(hydratable, requireHtmlDocumentRoot, chunkSink, chunkSize, prefix, content) + if (chunkSink != null) require(chunkSize > 0) { "chunkSize must be positive" } + + val pool = key?.let(::getHtmlRendererPool) + val renderer = pool?.borrow() ?: HtmlRenderer() + + // Return storage to the pool only after rendering, effect cleanup and snapshot disposal succeed. + val result = renderer.render(hydratable, requireHtmlDocumentRoot, chunkSink, chunkSize, prefix, content) + pool?.recycle(renderer) + return result } -private const val LARGE_BUFFER_THRESHOLD = 64 * 1024 -private const val BUFFER_SHRINK_FACTOR = 4 -private val renderers = mutableMapOf() +private val renderers = HtmlRendererPools(maxKeys = 64, maxIdleRenderers = 8) + +internal fun getHtmlRendererPool(key: String): HtmlRendererPool? = renderers[key] -/** Owns reusable rendering storage for a key, or for a single unkeyed render. */ -internal class HtmlRenderer(private val reusable: Boolean = true) { - private var attrsBuilders = AttrsBuilderPool() +/** Storage exclusively owned by one active render, then optionally returned to a pool. */ +internal class HtmlRenderer { + private val attrsBuilders = AttrsBuilderPool() private var output = StringBuilder() - private var composer = SinglePassComposer() - private var rendering = false + private val composer = SinglePassComposer() internal val bufferCapacity: Int get() = output.capacity() + internal val bufferLength: Int get() = output.length fun render( hydratable: Boolean, @@ -90,13 +90,9 @@ internal class HtmlRenderer(private val reusable: Boolean = true) { prefix: String, content: @Composable () -> Unit, ): String { - check(!rendering) { "Reentrant HTML rendering with the same key is not supported" } val snapshot = Snapshot.takeMutableSnapshot() - rendering = true try { - output.setLength(0) output.append(prefix) - composer.reset() val context = HtmlStringWriterContext(hydratable, attrsBuilders, output, chunkSink, chunkSize) return snapshot.enter { var failure: Throwable? = null @@ -109,7 +105,8 @@ internal class HtmlRenderer(private val reusable: Boolean = true) { composer.applyEffects() context.finish(requireHtmlDocumentRoot).also { if (isOversized(output.capacity(), context.peakBufferSize)) { - output.trimToSize() + if (chunkSink == null) output.trimToSize() + else output = StringBuilder(context.peakBufferSize) } } } catch (cause: Throwable) { @@ -119,20 +116,25 @@ internal class HtmlRenderer(private val reusable: Boolean = true) { composer.dispose(failure) } } - } catch (failure: Throwable) { - // Only retained renderers need replacement storage after a failed request. - if (reusable) { - attrsBuilders = AttrsBuilderPool() - output = StringBuilder() - composer = SinglePassComposer() - } - throw failure } finally { - rendering = false - snapshot.dispose() + try { + snapshot.dispose() + } finally { + // Clear the output before reuse. The underlying buffer is not erased. + output.setLength(0) + } } } } +internal fun htmlValidatedContent( + validateStrictly: Boolean, + content: @Composable () -> Unit, +): @Composable () -> Unit = { + CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) { + content() + } +} + private fun isOversized(capacity: Int, used: Int): Boolean = capacity > LARGE_BUFFER_THRESHOLD && used.toLong() * BUFFER_SHRINK_FACTOR < capacity diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.kt new file mode 100644 index 0000000000..0c13c8ea14 --- /dev/null +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web + +// Buffer policy uses the platform's reported capacity in UTF-16 code units. +// JVM reports backing capacity. JS and Wasm/JS report logical length, so after clearing +// the output their capacity is zero and the retention ceiling does not constrain backing storage. +internal const val LARGE_BUFFER_THRESHOLD = 64 * 1024 +internal const val BUFFER_SHRINK_FACTOR = 4 +internal const val MAX_RETAINED_BUFFER_CAPACITY = 2 * 1024 * 1024 + +/** Bounded registry without eviction. Empty pools retain their key slots; unregistered keys render uncached. */ +internal expect class HtmlRendererPools(maxKeys: Int, maxIdleRenderers: Int) { + operator fun get(key: String): HtmlRendererPool? +} + +/** + * Borrowers own their storage exclusively and recycle it only after successful rendering. + * Output buffers with reported capacity above [MAX_RETAINED_BUFFER_CAPACITY] are not retained. + * On JS and Wasm/JS, capacity is logical length, which is zero when recycling a cleared renderer. + */ +internal expect class HtmlRendererPool(maxIdleRenderers: Int) { + fun borrow(): HtmlRenderer + fun recycle(renderer: HtmlRenderer) +} 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 81a0025373..b4d5e039c3 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 @@ -20,24 +20,43 @@ import org.jetbrains.compose.web.dom.Script private const val HtmlDoctype = "" /** - * Renders one complete HTML document that may contain browser-hydrated content. - * - * [content] must produce exactly one `html` element. The returned string always starts with an - * HTML doctype. Static documents emit no hydration state. The application is responsible for - * loading the client code that calls `hydrateRoot`. - * Snapshot state changes made while rendering are discarded afterwards. - * On the JVM or JS/Node, `COMPOSE_HTML_VALIDATE_STRICTLY=true` enables strict validation and records that - * choice in each hydration state element. Pass `validateStrictly` to override the default. + * Renders a complete HTML document with optional browser hydration. + * + * [content] must produce exactly one `html` element. Output starts with an HTML doctype. + * Static documents emit no hydration state. + * + * Load client code that calls `hydrateRoot` after [HydrationRoot] and its state element are parsed: + * place the script after them, defer an external classic script, or use a module script. + * + * Snapshot state changes are discarded after rendering. + * + * On the JVM or Node.js, `COMPOSE_HTML_VALIDATE_STRICTLY=true` enables strict validation. + * [validateStrictly] overrides it. * Without strict validation, browser hydration trusts initial server values. * - * That client code must run only after the [HydrationRoot] and its state element have been parsed. - * Place its script after [HydrationRoot], defer an external classic script, or use a module script. - * Use [renderHydratedDocumentToStream] to emit the document incrementally. + * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. + * See [composeHtmlToStream] for validation and failure handling. + * + * Use [renderHydratedDocumentToStream] for incremental output. + * + * @param key stable key per template for best-effort storage reuse. `null` disables pooling. + */ +fun renderHydratedDocument( + validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, + key: String?, + content: @Composable () -> Unit, +): String = composeHydratedDocument(validateStrictly = validateStrictly, key = key, content = content) + +/** + * Renders a complete document with fresh storage. + * + * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * See [composeHtmlToStream] for validation and failure handling. */ fun renderHydratedDocument( validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, -): String = composeHydratedDocument(validateStrictly = validateStrictly, content = content) +): String = renderHydratedDocument(validateStrictly = validateStrictly, key = null, content = content) /** * Streams the same HTML as [renderHydratedDocument], including its doctype and hydration state. @@ -48,19 +67,38 @@ fun renderHydratedDocument( * may exceed the target. [sink] runs synchronously, may block, and receives no empty chunks. * Emitted output cannot be retracted on failure, including document validation after composition. * - * Each call releases its remembered state. Effects, snapshots, and buffering follow [composeHtmlToStream]. + * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * See [composeHtmlToStream] for validation and failure handling. * + * @param key when non-null, enables pooled rendering storage across calls and threads. * @param validateStrictly overrides validation and records the choice in hydration state. * @throws IllegalArgumentException if [chunkSize] is not positive or [content] is not a valid document. */ +fun renderHydratedDocumentToStream( + sink: (String) -> Unit, + chunkSize: Int = 2048, + validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, + key: String?, + content: @Composable () -> Unit, +) { + composeHydratedDocument( + validateStrictly, key = key, chunkSink = { sink(it.toString()) }, chunkSize = chunkSize, content = content, + ) +} + +/** + * Streams a complete document with fresh storage. + * + * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * See [composeHtmlToStream] for validation and failure handling. + */ fun renderHydratedDocumentToStream( sink: (String) -> Unit, chunkSize: Int = 2048, validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, ) { - require(chunkSize > 0) { "chunkSize must be positive" } - composeHydratedDocument(validateStrictly, chunkSink = { sink(it.toString()) }, chunkSize = chunkSize, content = content) + renderHydratedDocumentToStream(sink, chunkSize, validateStrictly, key = null, content = content) } /** Shared document context and protocol for complete-string and streaming output. */ @@ -68,6 +106,7 @@ internal fun composeHydratedDocument( validateStrictly: Boolean, chunkSink: ((StringBuilder) -> Unit)? = null, chunkSize: Int = 2048, + key: String? = null, content: @Composable () -> Unit, ): String { val seenHydrationIds = if (validateStrictly) mutableSetOf() else null @@ -76,6 +115,7 @@ internal fun composeHydratedDocument( chunkSink = chunkSink, chunkSize = chunkSize, prefix = HtmlDoctype, + key = key, ) { CompositionLocalProvider( LocalHydratedDocumentContext provides true, 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 0f23973c42..74ce15ccf0 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 @@ -124,9 +124,10 @@ internal fun StringBuilder.appendStartTag( ): Boolean { // Class validation runs even when an explicit class attribute overrides the class list. val classValue = builder.classes.toClassAttributeValue(validate) - val attributes = builder.collect().withFormState(formState) + val attributes = builder.collect().withFormState(formState).ifEmpty { emptyMap() } validateStringHtmlAttributes(attributes, namespace, validate, builder.hydrationProtocolAttributes) val hasScriptSource = tagName == "script" && attributes.keys.any { it.equals("src", ignoreCase = true) } + append('<').append(tagName) attributes.forEach { (name, value) -> appendAttribute(tagName, namespace, name, value) } if ("class" !in attributes && classValue != null) appendAttribute(tagName, namespace, "class", classValue) 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 fcec78e582..97a08fe7fc 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 @@ -26,7 +26,7 @@ import org.jetbrains.compose.web.internal.unsafeCast */ internal class HtmlStringWriterContext( private val hydratable: Boolean, - private val attrsBuilders: AttrsBuilderPool? = null, + private val attrsBuilders: AttrsBuilderPool = AttrsBuilderPool(), private val output: StringBuilder = StringBuilder(), private val chunkSink: ((StringBuilder) -> Unit)? = null, private val chunkSize: Int = 2048, @@ -40,7 +40,7 @@ internal class HtmlStringWriterContext( // These elements must keep their content in the shared buffer until their end tag is written. private var bufferedElementDepth = 0 - // Attribute builders are reused by element position across keyed renders. + // Reuse attribute channels initialized for each element position across keyed renders. private var attrsBuilderIndex = 0 // A null tag name means that output is being written at the root. @@ -182,7 +182,7 @@ internal class HtmlStringWriterContext( "Hydrated document content must produce exactly one html element" } } - attrsBuilders?.trim(attrsBuilderIndex) + attrsBuilders.trim(attrsBuilderIndex) if (!streaming) { peakBufferSize = output.length return output.toString() @@ -205,14 +205,16 @@ internal class HtmlStringWriterContext( if (currentTagName == null) recordRootChild(isHtml = tagName == "html") if (currentNamespace == HtmlNamespace) requireHtmlParserStableTableElement(currentTagName, tagName) - val attrsBuilder = attrsBuilders?.builder(attrsBuilderIndex++) ?: AttrsScopeBuilder() - applyAttrs?.invoke(attrsBuilder) - val formState = attrsBuilder.propertyUpdatesOrEmpty.formControlState(tagName, namespace) - val hasScriptSource = output.appendStartTag(tagName, namespace, attrsBuilder, validate, formState) - if (attrsBuilders != null) attrsBuilder.reset() - - pendingLeadingNewline = false // A child tag starts with '<', which needs no LF repair. - return hasScriptSource to formState?.textContent + val builder = attrsBuilders.builder(attrsBuilderIndex++) + try { + applyAttrs?.invoke(builder) + val formState = builder.propertyUpdatesOrEmpty.formControlState(tagName, namespace) + val hasScriptSource = output.appendStartTag(tagName, namespace, builder, validate, formState) + pendingLeadingNewline = false // A child tag starts with '<', which needs no LF repair. + return hasScriptSource to formState?.textContent + } finally { + builder.reset() + } } private fun writeEndTag( @@ -268,7 +270,7 @@ internal class HtmlStringWriterContext( private fun elementScope(): ElementScope = elementScope.unsafeCast() } -/** Reuses attribute builders across keyed renders, indexed by element order, without retaining their values. */ +/** Reuses attribute builders by element order without retaining their values between elements. */ internal class AttrsBuilderPool { private val builders = mutableListOf>() @@ -277,7 +279,7 @@ internal class AttrsBuilderPool { return builders[index].unsafeCast() } - /** Drops builders beyond [size] that the last render no longer needed. */ + /** Drops builders beyond [size] when the last successful render used fewer elements. */ fun trim(size: Int) { if (size < builders.size) builders.subList(size, builders.size).clear() } diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HtmlRendererPoolTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HtmlRendererPoolTest.kt new file mode 100644 index 0000000000..b727dd70f6 --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HtmlRendererPoolTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web + +import androidx.compose.runtime.DisposableEffect +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Text +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class HtmlRendererPoolTest { + @Test + fun invalidChunkSizesDoNotBorrowStorageComposeOrEmit() { + val key = "invalid-chunk-size" + val pool = assertNotNull(getHtmlRendererPool(key)) + val renderer = pool.borrow() + pool.recycle(renderer) + val chunks = mutableListOf() + var composed = false + + for (size in listOf(0, -1)) { + val failure = assertFailsWith { + composeHtmlToStream(chunks::add, chunkSize = size, key = key) { + composed = true + Text("unused") + } + } + assertEquals("chunkSize must be positive", failure.message) + } + assertFalse(composed) + assertTrue(chunks.isEmpty()) + assertSame(renderer, pool.borrow()) + } + + @Test + fun attributesDoNotLeakIntoChildrenSiblingsOrLaterRequests() { + for (key in listOf(null, "attribute-reset")) { + assertEquals( + "
child
sibling
", + composeHtmlToString(key = key, validateStrictly = true) { + Div(attrs = { attr("data-user", "first"); classes("parent") }) { + Div { Text("child") } + } + Div { Text("sibling") } + }, + ) + assertEquals("
next
", composeHtmlToString(key = key) { Div { Text("next") } }) + } + } + + @Test + fun outputIsClearedAfterSuccessAndContentSinkOrDisposalFailure() { + for (failure in listOf(null, "content", "sink", "disposal")) { + val renderer = HtmlRenderer() + val render = { + renderer.render(false, false, if (failure == "sink") { + { throw IllegalStateException("sink") } + } else null, 1, "prefix") { + DisposableEffect(Unit) { + onDispose { if (failure == "disposal") error("disposal") } + } + Text("request-data") + if (failure == "content") error("content") + } + } + if (failure == null) assertEquals("prefixrequest-data", render()) + else assertFailsWith { render() } + assertEquals(0, renderer.bufferLength) + } + } + + @Test + fun activeRendersHaveExclusiveStorageAndSuccessfulStorageCanBeReused() { + val pool = HtmlRendererPool(maxIdleRenderers = 2) + val first = pool.borrow() + val second = pool.borrow() + assertNotSame(first, second) + pool.recycle(first) + assertSame(first, pool.borrow()) + assertNotSame(second, pool.borrow()) + } + + @Test + fun idleRetentionIsBoundedAndOverflowDoesNotPreventBorrowing() { + for (capacity in listOf(0, 1, 4)) { + val pool = HtmlRendererPool(maxIdleRenderers = capacity) + val renderers = List(8) { pool.borrow() } + renderers.forEach(pool::recycle) + val borrowed = List(8) { pool.borrow() } + assertEquals(capacity, borrowed.count { it in renderers }) + assertEquals(8, borrowed.toSet().size) + } + } + + @Test + fun dynamicKeysDoNotGrowTheRegistryPastItsLimit() { + val pools = HtmlRendererPools(maxKeys = 2, maxIdleRenderers = 1) + val first = assertNotNull(pools["template-first"]) + val second = assertNotNull(pools["template-second"]) + repeat(1000) { assertNull(pools["request-$it"]) } + assertSame(first, pools["template-first"]) + assertSame(second, pools["template-second"]) + assertNull(HtmlRendererPools(maxKeys = 0, maxIdleRenderers = 1)["uncached"]) + } + + @Test + fun emptyRegisteredPoolsAreNotEvictedForLaterTemplateKeys() { + val pools = HtmlRendererPools(maxKeys = 1, maxIdleRenderers = 1) + val earlyRequestPool = assertNotNull(pools["early-request-id"]) + val activeRenderer = earlyRequestPool.borrow() + assertNull(pools["real-template"]) + earlyRequestPool.recycle(activeRenderer) + assertNull(pools["real-template"]) + assertSame(earlyRequestPool, pools["early-request-id"]) + assertSame(activeRenderer, earlyRequestPool.borrow()) + } + + @Test + fun failedStorageIsDiscardedAfterContentSinkOrDisposalFailure() { + val key = "discard-failed-renderer" + val pool = assertNotNull(getHtmlRendererPool(key)) + for (failure in listOf("content", "sink", "disposal")) { + val renderer = pool.borrow() + pool.recycle(renderer) + val cause = IllegalStateException(failure) + assertSame(cause, assertFailsWith { + composeHtmlToStream( + sink = { if (failure == "sink") throw cause }, + key = key, + chunkSize = 1, + ) { + DisposableEffect(Unit) { + onDispose { if (failure == "disposal") throw cause } + } + if (failure == "content") throw cause + Text("failed") + } + }) + val replacement = pool.borrow() + assertNotSame(renderer, replacement) + pool.recycle(replacement) + assertEquals("recovered", composeHtmlToString(key = key) { Text("recovered") }) + } + } + + @Test + fun caughtSameKeyNestedFailurePreservesTheOuterRender() { + assertEquals("
beforeafter
", composeHtmlToString(key = "nested-failure") { + Div { + Text("before") + assertFailsWith { + composeHtmlToString(key = "nested-failure") { Text("invalid\u0000text") } + } + Text("after") + } + }) + assertEquals("next", composeHtmlToString(key = "nested-failure") { Text("next") }) + } +} diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentPoolTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentPoolTest.kt new file mode 100644 index 0000000000..ab5a6b8852 --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentPoolTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import org.jetbrains.compose.web.dom.Body +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Html +import org.jetbrains.compose.web.dom.Text +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class HydratedDocumentPoolTest { + @Test + fun documentsStreamsAndFragmentsShareStorageWithFreshRequestState() { + val key = "document-request-state" + val pool = assertNotNull(getHtmlRendererPool(key)) + val renderer = pool.borrow() + pool.recycle(renderer) + val snapshotState = mutableStateOf("outside") + var remembered = 0 + var disposals = 0 + for (stream in listOf(false, true, false, true)) { + val request = "request-${remembered + 1}" + val strict = !stream + val rendered = render(stream, key, strict) { + val value = remember { ++remembered; request } + snapshotState.value = request + DisposableEffect(Unit) { onDispose { disposals++ } } + Html { + Body { + HydrationRoot(value, { it }, hydrationId = "application", rootAttrs = { + if (strict) classes("first") + }) { Text(it) } + } + } + } + assertTrue(rendered.startsWith("")) + assertContains(rendered, "$request") + assertContains(rendered, "$request") + assertEquals(strict, "class=\"first\"" in rendered) + assertEquals(strict, HydrationValidationAttribute in rendered) + assertEquals("outside", snapshotState.value) + assertEquals(remembered, disposals) + assertSame(renderer, pool.borrow()) + pool.recycle(renderer) + assertEquals("fragment", composeHtmlToString(key = key) { Text("fragment") }) + assertSame(renderer, pool.borrow()) + pool.recycle(renderer) + } + assertEquals(4, remembered) + // Document locals must not leak into the next fragment using the same renderer. + assertFailsWith { + composeHtmlToString(key = key) { HydrationRoot(Unit, { "null" }) {} } + } + } + + @Test + fun failuresDiscardDocumentStorageAndTheKeyCanRenderAgain() { + val key = "document-failure-storage" + val pool = assertNotNull(getHtmlRendererPool(key)) + for (stream in listOf(false, true)) { + for (failure in listOf("content", "serializer", "validation", "duplicate-id", "disposal", "sink")) { + if (!stream && failure == "sink") continue + val renderer = pool.borrow() + pool.recycle(renderer) + val cause = IllegalStateException(failure) + val content: @Composable () -> Unit = { + DisposableEffect(Unit) { onDispose { if (failure == "disposal") throw cause } } + if (failure == "content") throw cause + if (failure == "validation") Div() else Html { + Body { + HydrationRoot(Unit, { if (failure == "serializer") throw cause; "state" }, hydrationId = "app") {} + if (failure == "duplicate-id") HydrationRoot(Unit, { "state" }, hydrationId = "app") {} + } + } + } + if (failure == "validation" || failure == "duplicate-id") { + assertFailsWith { render(stream, key, true, content = content) } + } else { + assertSame(cause, assertFailsWith { + if (failure == "sink") { + renderHydratedDocumentToStream({ throw cause }, chunkSize = 1, key = key, content = content) + } else render(stream, key, true, content = content) + }) + } + val replacement = pool.borrow() + assertNotSame(renderer, replacement) + pool.recycle(replacement) + assertEquals( + "recovered", + render(stream, key, true) { Html { Body { Text("recovered") } } }, + ) + } + } + } + + @Test + fun sameKeyNestedDocumentsKeepHydrationIdsAndOutputIndependent() { + val key = "nested-document" + var nested = "" + val outer = renderHydratedDocument(key = key, validateStrictly = true) { + Html { + Body { + HydrationRoot("outer", { it }, hydrationId = "app") { + nested = render(true, key, true) { + Html { Body { HydrationRoot("inner", { it }, hydrationId = "app") { Text(it) } } } + } + assertFailsWith { + renderHydratedDocument(key = key) { Div() } + } + Text(it) + } + HydrationRoot("after", { it }, hydrationId = "after") { Text(it) } + } + } + } + assertContains(outer, ">outer") + assertFalse("inner" in outer) + assertContains(nested, ">inner") + assertFalse("outer" in nested) + } + + @Test + fun sameKeyNestedRenderFromTheSinkKeepsTheOuterChunksIntact() { + val key = "document-nested-sink" + val chunks = mutableListOf() + renderHydratedDocumentToStream( + sink = { chunk -> + assertEquals( + "inner", + renderHydratedDocument(key = key) { Html { Body { Text("inner") } } }, + ) + chunks.add(chunk) + }, + chunkSize = 1, + key = key, + ) { Html { Body { Text("outer") } } } + assertEquals("outer", chunks.joinToString("")) + } + + @Test + fun invalidStreamSizesDoNotBorrowStorageComposeOrEmit() { + val key = "document-invalid-size" + val pool = assertNotNull(getHtmlRendererPool(key)) + val renderer = pool.borrow() + pool.recycle(renderer) + var composed = false + var emitted = false + for (size in listOf(0, -1)) { + assertFailsWith { + renderHydratedDocumentToStream({ emitted = true }, chunkSize = size, key = key) { + composed = true + Html() + } + } + } + assertFalse(composed) + assertFalse(emitted) + assertSame(renderer, pool.borrow()) + } + + private fun render( + stream: Boolean, + key: String, + strict: Boolean, + content: @Composable () -> Unit, + ): String = if (stream) buildString { + renderHydratedDocumentToStream({ append(it) }, chunkSize = 1, validateStrictly = strict, key = key, content = content) + } else renderHydratedDocument(validateStrictly = strict, key = key, content = content) +} diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt index f24a5c08a6..f8ad3aef7b 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt @@ -49,6 +49,15 @@ class SinglePassRenderingTest { assertTrue(chunks.size > 1) } + @Test + fun stringRenderingDoesNotRequireAPositiveChunkSize() { + for (size in listOf(0, -1)) { + assertEquals("
complete
", composeHtmlString(chunkSize = size) { + Div { Text("complete") } + }) + } + } + @Test fun nestedComputedDefaultAndNullableLocalsRestoreTheirScopes() { val number = compositionLocalOf { 2 } @@ -375,20 +384,18 @@ class SinglePassRenderingTest { } @Test - fun nestedRenderRestoresContextAndSameKeyReentryIsRejected() { - assertEquals("
outer<span>inner</span>tail
", composeHtmlToString(key = "outer") { - Div { - Text("outer") - Text(composeHtmlToString(key = "inner") { Span { Text("inner") } }) - Text("tail") - } - }) - assertFailsWith { - composeHtmlToString(key = "reentrant") { - composeHtmlToString(key = "reentrant") { Text("bad") } - } + fun nestedRenderRestoresContextWithTheSameOrDifferentKey() { + for (innerKey in listOf("inner", "outer")) { + assertEquals("
outer<span>inner</span>tail
", + composeHtmlToString(key = "outer") { + Div { + Text("outer") + Text(composeHtmlToString(key = innerKey) { Span { Text("inner") } }) + Text("tail") + } + }) } - assertEquals("ok", composeHtmlToString(key = "reentrant") { Text("ok") }) + assertEquals("ok", composeHtmlToString(key = "outer") { Text("ok") }) } private enum class HashKey { First, Second } diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToStreamTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToStreamTest.kt index 6e2f2dd037..a33308e1f3 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToStreamTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToStreamTest.kt @@ -6,6 +6,8 @@ package org.jetbrains.compose.web.dom import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import kotlinx.browser.dom.Element import org.jetbrains.compose.web.composeHtmlToStream import org.jetbrains.compose.web.composeHtmlToString @@ -185,4 +187,56 @@ class ComposeHtmlToStreamTest { } assertEquals("second", composeHtmlToString(key = "stream-sink-failure") { Text("second") }) } + + @Test + fun sameKeyReentryFromSinkPreservesTheOuterRender() { + val chunks = mutableListOf() + composeHtmlToStream( + sink = { + assertEquals("inner", composeHtmlToString(key = "stream-reentry") { Text("inner") }) + chunks.add(it) + }, + chunkSize = 1, + key = "stream-reentry", + ) { Div { Text("outer") } } + assertEquals("
outer
", chunks.joinToString("")) + assertEquals("next", composeHtmlToString(key = "stream-reentry") { Text("next") }) + } + + @Test + fun differentKeyReentryFromSinkPreservesTheOuterRender() { + val chunks = mutableListOf() + composeHtmlToStream( + sink = { + assertEquals("inner", composeHtmlToString(key = "stream-inner") { + Span { Text("inner") } + }) + chunks.add(it) + }, + chunkSize = 1, + key = "stream-outer", + ) { Div { Text("first"); Text("second") } } + assertEquals("
firstsecond
", chunks.joinToString("")) + } + + @Test + fun sameKeyReentryFromEffectsPreservesTheOuterRender() { + val effects = mutableListOf() + fun checkReentry(effect: String) { + val chunks = mutableListOf() + composeHtmlToStream(sink = chunks::add, key = "effect-reentry") { Text("inner") } + assertEquals(listOf("inner"), chunks) + effects.add(effect) + } + assertEquals("outer", composeHtmlToString(key = "effect-reentry") { + DisposableEffect(Unit) { + checkReentry("enter") + onDispose { checkReentry("dispose") } + } + SideEffect { checkReentry("side") } + Text("outer") + }) + assertEquals(listOf("enter", "side", "dispose"), effects) + assertEquals("next", composeHtmlToString(key = "effect-reentry") { Text("next") }) + } } 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 7dab813931..56bb30016a 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 @@ -147,21 +147,18 @@ class DirectStringRenderingTest { } @Test - fun nestedRenderRestoresContextAndSameKeyReentryIsRejected() { - assertEquals("
outer<span>inner</span>tail
", - composeHtmlToString(key = "outer") { - Div { - Text("outer") - Text(composeHtmlToString(key = "inner") { Span { Text("inner") } }) - Text("tail") - } - }) - assertFailsWith { - composeHtmlToString(key = "reentrant") { - composeHtmlToString(key = "reentrant") { Text("bad") } - } + fun nestedRenderRestoresContextWithTheSameOrDifferentKey() { + for (innerKey in listOf("inner", "outer")) { + assertEquals("
outer<span>inner</span>tail
", + composeHtmlToString(key = "outer") { + Div { + Text("outer") + Text(composeHtmlToString(key = innerKey) { Span { Text("inner") } }) + Text("tail") + } + }) } - assertEquals("ok", composeHtmlToString(key = "reentrant") { Text("ok") }) + assertEquals("ok", composeHtmlToString(key = "outer") { Text("ok") }) } } 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 5e02f64867..68ddfda66c 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 @@ -8,7 +8,6 @@ package org.jetbrains.compose.web import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import java.io.Writer /** @@ -19,9 +18,19 @@ import java.io.Writer * intact. Elements requiring content validation are emitted only after validation succeeds. * A write can exceed [chunkSize]. No empty writes are made. * - * Storage reuse, effects, validation, and failure handling follow [composeHtmlToStream] - * with a String sink. Keyed renders are not thread-safe, even with different keys. - * Output already written cannot be retracted on failure, and failed rendering storage is reset. + * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * See [composeHtmlToStream] for validation and failure handling. + * + * Output already written cannot be retracted on failure, and failed rendering storage is discarded. + * + * @param writer receives the HTML chunks. It is not flushed or closed. + * @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. + * @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. + * @param content the composable content to render once. */ fun composeHtmlToStream( writer: Writer, @@ -31,15 +40,10 @@ fun composeHtmlToStream( validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, ) { - require(chunkSize > 0) { "chunkSize must be positive" } val chunkSink = htmlWriterSink(writer) - val validatedContent: @Composable () -> Unit = { - CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) { - content() - } - } composeHtmlString( - hydratable = hydratable, key = key, chunkSink = chunkSink, chunkSize = chunkSize, content = validatedContent, + hydratable = hydratable, key = key, chunkSink = chunkSink, chunkSize = chunkSize, + content = htmlValidatedContent(validateStrictly, content), ) } diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.jvm.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.jvm.kt new file mode 100644 index 0000000000..06ac4686ba --- /dev/null +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.jvm.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.atomic.AtomicReferenceArray + +internal actual class HtmlRendererPools actual constructor( + private val maxKeys: Int, + private val maxIdleRenderers: Int, +) { + private val pools = ConcurrentHashMap() + private val keyCount = AtomicInteger() + + actual operator fun get(key: String): HtmlRendererPool? = pools[key] ?: register(key) + + private fun register(key: String): HtmlRendererPool? { + // Avoid counter writes when capacity is already reserved; a racing insert may have published this key. + if (keyCount.get() >= maxKeys) return pools[key] + // Reserve capacity before insertion: ConcurrentHashMap.size cannot enforce a concurrent cap. + if (keyCount.incrementAndGet() > maxKeys) { + keyCount.decrementAndGet() + return pools[key] + } + val pool = HtmlRendererPool(maxIdleRenderers) + val existing = pools.putIfAbsent(key, pool) + if (existing != null) keyCount.decrementAndGet() + return existing ?: pool + } +} + +internal actual class HtmlRendererPool actual constructor(private val maxIdleRenderers: Int) { + // The first slot keeps the usual single-renderer path short. Overflow slots bound total + // retention without queue nodes or an idle counter. All publication transfers ownership. + private val first = AtomicReference() + private val overflow = AtomicReferenceArray((maxIdleRenderers - 1).coerceAtLeast(0)) + + actual fun borrow(): HtmlRenderer = first.getAndSet(null) ?: borrowOverflow() + + private fun borrowOverflow(): HtmlRenderer { + for (index in 0 until overflow.length()) { + val renderer = overflow.get(index) + if (renderer != null && overflow.compareAndSet(index, renderer, null)) return renderer + } + return HtmlRenderer() + } + + actual fun recycle(renderer: HtmlRenderer) { + if (maxIdleRenderers <= 0 || renderer.bufferCapacity > MAX_RETAINED_BUFFER_CAPACITY) return + if (!first.compareAndSet(null, renderer)) recycleOverflow(renderer) + } + + private fun recycleOverflow(renderer: HtmlRenderer) { + for (index in 0 until overflow.length()) { + if (overflow.compareAndSet(index, null, renderer)) return + } + } +} diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt index a0fe792a5c..96d9ba8de1 100644 --- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt @@ -18,18 +18,39 @@ import java.io.Writer * [chunkSize] is a target minimum in UTF-16 code units. A write can exceed it. The writer must * consume each write before returning because the character array is reused. The writer is not flushed * or closed. Output already written cannot be retracted if rendering, validation or writing throws. + * + * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * See [composeHtmlToStream] for validation and failure handling. + * + * @param key when non-null, enables pooled rendering storage across calls and threads. */ fun renderHydratedDocumentToStream( writer: Writer, chunkSize: Int = 2048, validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, + key: String?, content: @Composable () -> Unit, ) { - require(chunkSize > 0) { "chunkSize must be positive" } composeHydratedDocument( validateStrictly, + key = key, chunkSize = chunkSize, chunkSink = htmlWriterSink(writer), content = content, ) } + +/** + * Streams a complete document with fresh storage. + * + * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * See [composeHtmlToStream] for validation and failure handling. + */ +fun renderHydratedDocumentToStream( + writer: Writer, + chunkSize: Int = 2048, + validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, + content: @Composable () -> Unit, +) { + renderHydratedDocumentToStream(writer, chunkSize, validateStrictly, key = null, content = content) +} diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/ComposeHtmlConcurrencyTest.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/ComposeHtmlConcurrencyTest.kt new file mode 100644 index 0000000000..1d4f6890b0 --- /dev/null +++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/ComposeHtmlConcurrencyTest.kt @@ -0,0 +1,392 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import java.io.StringWriter +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import org.jetbrains.compose.web.dom.Body +import org.jetbrains.compose.web.dom.Html +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import org.junit.Assume.assumeNotNull +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.test.assertSame +import kotlin.test.assertNotSame +import kotlin.test.assertNotNull + +class ComposeHtmlConcurrencyTest { + @Test + fun concurrentCallsWithTheSameKeyHaveIndependentStorage() { + assertIndependentRenders { "concurrent-shared-key" } + } + + @Test + fun concurrentCallsWithDifferentKeysHaveIndependentStorage() { + // Equal hashes also exercise concurrent insertion into the renderer cache. + val keys = listOf("AaAa", "AaBB", "BBAa", "BBBB", "AaAaAa", "BBAaAa") + assertIndependentRenders { "concurrent-${keys[it]}" } + } + + @Test + fun concurrentUnkeyedCallsHaveIndependentStorage() { + assertIndependentRenders { null } + } + + private fun assertIndependentRenders(keyForWorker: (Int) -> String?) { + val state = mutableStateOf("outside") + val overlap = CyclicBarrier(6) + runConcurrently(6) { worker -> + repeat(10) { iteration -> + val request = "request-$worker-$iteration" + val effects = mutableListOf() + val thread = Thread.currentThread() + val hydratable = iteration % 2 == 0 + val content: @Composable () -> Unit = { + CompositionLocalProvider(LocalRequest provides request) { + val remembered = remember { request } + state.value = request + DisposableEffect(Unit) { + assertEquals(thread, Thread.currentThread()) + effects.add("enter $request") + onDispose { + assertEquals(thread, Thread.currentThread()) + effects.add("dispose $request") + } + } + SideEffect { + assertEquals(thread, Thread.currentThread()) + effects.add("side $request") + } + Div(attrs = { id(request) }) { + Text(remembered) + overlap.await(10, TimeUnit.SECONDS) + assertEquals(request, LocalRequest.current) + assertEquals(request, state.value) + Text("<&>😀") + if (iteration % 2 == 0) Span { Text(request) } + } + } + } + val boundary = if (hydratable) "" else "" + val child = if (iteration % 2 == 0) "$request" else "" + assertEquals( + "
$request${boundary}<&>😀$child
", + render((worker + iteration) % 3, keyForWorker(worker), hydratable, content), + ) + assertEquals(listOf("enter $request", "side $request", "dispose $request"), effects) + assertEquals("outside", state.value) + } + } + assertEquals("outside", state.value) + } + + @Test + fun concurrentDocumentsShareAKeyAcrossStringStreamAndWriterWithIndependentState() { + val key = "concurrent-documents" + val state = mutableStateOf("outside") + val overlap = CyclicBarrier(6) + runConcurrently(6) { worker -> + repeat(10) { iteration -> + val request = "request-$worker-$iteration" + val strict = iteration % 2 == 0 + var disposals = 0 + val content: @Composable () -> Unit = { + val remembered = remember { request } + state.value = request + DisposableEffect(Unit) { onDispose { disposals++ } } + Html { + Body { + HydrationRoot(remembered, { it }, hydrationId = "app") { + overlap.await(10, TimeUnit.SECONDS) + assertEquals(request, state.value) + assertEquals(request, composeHtmlToString(key = key) { Text(request) }) + Text(remembered) + } + } + } + } + val actual = when ((worker + iteration) % 3) { + 0 -> renderHydratedDocument(strict, key, content) + 1 -> buildString { + renderHydratedDocumentToStream({ append(it) }, 1, strict, key, content) + } + else -> StringWriter().also { + renderHydratedDocumentToStream(it, 1, strict, key, content) + }.toString() + } + val validation = if (strict) " data-compose-hydration-validation=\"on\"" else "" + assertEquals( + "
$request
" + + "", + actual, + ) + assertEquals(1, disposals) + assertEquals("outside", state.value) + } + } + } + + @Test + fun blockedSinkDoesNotBlockOtherCallsEvenWithTheSameKey() { + for (otherKey in listOf("blocked-sink", "independent-sink")) { + val sinkEntered = CountDownLatch(1) + val otherRenderFinished = CountDownLatch(1) + runConcurrently(2) { worker -> + if (worker == 0) { + val chunks = mutableListOf() + composeHtmlToStream( + sink = { + chunks.add(it) + sinkEntered.countDown() + assertTrue(otherRenderFinished.await(10, TimeUnit.SECONDS)) + }, + chunkSize = 1, + key = "blocked-sink", + ) { Text("first") } + assertEquals(listOf("first"), chunks) + } else { + assertTrue(sinkEntered.await(10, TimeUnit.SECONDS)) + try { + assertEquals("second", composeHtmlToString(key = otherKey) { Text("second") }) + } finally { + otherRenderFinished.countDown() + } + } + } + } + } + + @Test + fun concurrentNestedRendersWithOppositeKeyOrderDoNotDeadlock() { + val overlap = CyclicBarrier(2) + runConcurrently(2) { worker -> + val outerKey = "nested-$worker" + val innerKey = "nested-${1 - worker}" + val html = composeHtmlToString(key = outerKey) { + Text("before") + overlap.await(10, TimeUnit.SECONDS) + val inner = composeHtmlToString(key = innerKey) { Span { Text(innerKey) } } + Text(inner) + Text("after") + } + assertEquals("before<span>$innerKey</span>after", html) + } + } + + @Test + fun failedCallsDoNotCorruptConcurrentCallsAndTheirThreadCanReuseTheKey() { + for (failure in listOf("content", "sink", "disposal")) { + val overlap = CyclicBarrier(2) + runConcurrently(2) { worker -> + val key = "concurrent-failure-$failure" + if (worker == 0) { + val cause = IllegalStateException(failure) + val caught = assertFailsWith { + composeHtmlToStream( + sink = { if (failure == "sink") throw cause }, + chunkSize = 1, + key = key, + ) { + DisposableEffect(Unit) { + onDispose { if (failure == "disposal") throw cause } + } + overlap.await(10, TimeUnit.SECONDS) + if (failure == "content") throw cause + Text("failing") + } + } + assertEquals(cause, caught) + assertEquals("recovered", composeHtmlToString(key = key) { Text("recovered") }) + } else { + assertEquals("
healthy
", composeHtmlToString(key = key) { + Div { + overlap.await(10, TimeUnit.SECONDS) + Text("healthy") + } + }) + } + } + } + } + + @Test + fun renderingStorageCanBeReusedOnAnotherThread() { + val key = "cross-thread-reuse" + val first = AtomicReference() + repeat(2) { request -> + val executor = Executors.newSingleThreadExecutor() + try { + executor.submit { + assertEquals("request-$request", composeHtmlToString(key = key) { Text("request-$request") }) + val pool = assertNotNull(getHtmlRendererPool(key)) + val renderer = pool.borrow() + if (request == 0) first.set(renderer) else assertSame(first.get(), renderer) + pool.recycle(renderer) + }.get(10, TimeUnit.SECONDS) + } finally { + executor.shutdownNow() + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)) + } + } + } + + @Test + fun virtualThreadsReuseStorageBetweenRequests() { + val factory = runCatching { Executors::class.java.getMethod("newVirtualThreadPerTaskExecutor") }.getOrNull() + assumeNotNull(factory) // Run on JDK 21+, skip on older test runtimes. + val executor = factory!!.invoke(null) as ExecutorService + val first = AtomicReference() + var previousThread: Thread? = null + try { + repeat(3) { request -> + executor.submit { + assertNotSame(previousThread, Thread.currentThread()) + previousThread = Thread.currentThread() + val key = "virtual-thread-reuse" + assertEquals("request-$request", composeHtmlToString(key = key) { Text("request-$request") }) + val pool = assertNotNull(getHtmlRendererPool(key)) + val renderer = pool.borrow() + if (request == 0) first.set(renderer) else assertSame(first.get(), renderer) + pool.recycle(renderer) + }.get(10, TimeUnit.SECONDS) + } + } finally { + executor.shutdownNow() + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)) + } + } + + @Test + fun registryKeyLimitIsRespectedDuringConcurrentRegistration() { + for (limit in listOf(0, 1, 4, 32)) { + val pools = HtmlRendererPools(maxKeys = limit, maxIdleRenderers = 1) + val admitted = java.util.concurrent.ConcurrentHashMap.newKeySet() + runConcurrently(8) { worker -> + repeat(100) { request -> + val key = "template-$worker-$request" + if (pools[key] != null) admitted.add(key) + } + } + assertEquals(limit, admitted.size) + admitted.forEach { assertNotNull(pools[it]) } + assertEquals(null, pools["overflow"]) + } + } + + @Test + fun fullRegistryKeepsKnownKeysAvailableDuringConcurrentMisses() { + val pools = HtmlRendererPools(maxKeys = 2, maxIdleRenderers = 1) + // These keys collide, so known and unknown lookups also share map buckets. + val first = assertNotNull(pools["AaAa"]) + val second = assertNotNull(pools["AaBB"]) + runConcurrently(8) { + repeat(1000) { + assertSame(first, pools["AaAa"]) + assertEquals(null, pools["BBAa"]) + assertSame(second, pools["AaBB"]) + assertEquals(null, pools["BBBB"]) + } + } + } + + @Test + fun concurrentRegistrationOfOneKeyDoesNotConsumeOtherKeySlots() { + val pools = HtmlRendererPools(maxKeys = 8, maxIdleRenderers = 1) + runConcurrently(8) { assertNotNull(pools["shared"]) } + repeat(7) { assertNotNull(pools["other-$it"]) } + assertEquals(null, pools["overflow"]) + } + + @Test + fun concurrentReturnsRespectTheIdleLimitAndBorrowersHaveExclusiveStorage() { + val pool = HtmlRendererPool(maxIdleRenderers = 4) + val borrowed = Array(8) { AtomicReference() } + val overlap = CyclicBarrier(8) + repeat(10) { + runConcurrently(8) { worker -> + val renderer = pool.borrow() + borrowed[worker].set(renderer) + overlap.await(10, TimeUnit.SECONDS) + assertEquals(8, borrowed.map { it.get() }.toSet().size) + overlap.await(10, TimeUnit.SECONDS) + pool.recycle(renderer) + } + val retained = List(8) { pool.borrow() } + assertEquals(4, retained.count { renderer -> borrowed.any { it.get() === renderer } }) + retained.forEach(pool::recycle) + } + } + + @Test + fun overlappingBorrowAndReturnCyclesKeepExclusiveOwnership() { + for (capacity in listOf(1, 4, 8)) { + val pool = HtmlRendererPool(maxIdleRenderers = capacity) + val active = ConcurrentHashMap.newKeySet() + runConcurrently(12) { + repeat(5000) { iteration -> + val renderer = pool.borrow() + assertTrue(active.add(renderer), "A renderer was borrowed by two workers") + if (iteration % 8 == 0) Thread.yield() + assertTrue(active.remove(renderer)) + pool.recycle(renderer) + } + } + assertTrue(active.isEmpty()) + } + } + + private fun render( + mode: Int, + key: String?, + hydratable: Boolean, + content: @Composable () -> Unit, + ): String = when (mode) { + 0 -> composeHtmlToString(hydratable, key, validateStrictly = true, content = content) + 1 -> buildString { + composeHtmlToStream(sink = { append(it) }, chunkSize = 1, hydratable, key, content = content) + } + else -> StringWriter().also { + composeHtmlToStream(it, chunkSize = 1, hydratable, key, content = content) + }.toString() + } + + private fun runConcurrently(workers: Int, action: (Int) -> Unit) { + val executor = Executors.newFixedThreadPool(workers) + val start = CyclicBarrier(workers) + try { + val futures = (0 until workers).map { worker -> + executor.submit { + start.await(10, TimeUnit.SECONDS) + action(worker) + } + } + futures.forEach { it.get(20, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)) + } + } +} + +private val LocalRequest = staticCompositionLocalOf { "outside" } diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HtmlRendererPoolRetentionTest.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HtmlRendererPoolRetentionTest.kt new file mode 100644 index 0000000000..d020446f64 --- /dev/null +++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HtmlRendererPoolRetentionTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web + +import java.io.StringWriter +import kotlinx.browser.dom.Element +import org.jetbrains.compose.web.dom.TagElement +import org.jetbrains.compose.web.dom.Text +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +class HtmlRendererPoolRetentionTest { + @Test + fun largeStringBelowTwoMiCeilingReusesItsRenderer() { + val key = "retention-large-reusable-string" + val renderer = seedRenderer(key) + val text = "α".repeat(1_747_011) + assertEquals(text, composeHtmlToString(hydratable = false, key = key) { Text(text) }) + assertSame(renderer, assertNotNull(getHtmlRendererPool(key)).borrow()) + } + + @Test + fun capacityAtTheCeilingIsRetainedAndOneUnitOverItIsDiscarded() { + val highWater = "α".repeat(MAX_RETAINED_BUFFER_CAPACITY * 8) + for (extra in 0..1) { + val pool = HtmlRendererPool(maxIdleRenderers = 1) + val renderer = pool.borrow() + render(renderer, highWater) + // A smaller render trims to its exact length, allowing an exact capacity boundary test. + val text = "α".repeat(MAX_RETAINED_BUFFER_CAPACITY + extra) + assertEquals(text, render(renderer, text)) + assertEquals(text.length, renderer.bufferCapacity) + pool.recycle(renderer) + val next = pool.borrow() + if (extra == 0) assertSame(renderer, next) else assertNotSame(renderer, next) + + // Rejection must not consume the only idle slot. + assertEquals("small", render(next, "small")) + pool.recycle(next) + assertSame(next, pool.borrow()) + } + } + + @Test + fun oversizedStringOutputIsCompleteAndItsRendererIsDiscarded() { + val key = "retention-large-string" + val renderer = seedRenderer(key) + val text = "α".repeat(MAX_RETAINED_BUFFER_CAPACITY + 1) + assertEquals(text, composeHtmlToString(hydratable = false, key = key) { Text(text) }) + assertNotSame(renderer, assertNotNull(getHtmlRendererPool(key)).borrow()) + assertEquals("next", composeHtmlToString(key = key) { Text("next") }) + } + + @Test + fun largeStreamingOutputWithSmallChunksStillReusesItsRenderer() { + val key = "retention-small-chunks" + val renderer = seedRenderer(key) + val chunk = "α".repeat(1024) + val chunks = mutableListOf() + val count = MAX_RETAINED_BUFFER_CAPACITY / chunk.length + 1 + composeHtmlToStream(chunks::add, chunkSize = chunk.length, hydratable = false, key = key) { + repeat(count) { Text(chunk) } + } + assertEquals(List(count) { chunk }, chunks) + assertSame(renderer, assertNotNull(getHtmlRendererPool(key)).borrow()) + } + + @Test + fun oversizedValidatedWriterChunkIsCompleteAndItsRendererIsDiscarded() { + val key = "retention-large-writer-chunk" + val renderer = seedRenderer(key) + val text = "α".repeat(MAX_RETAINED_BUFFER_CAPACITY + 1) + val writer = StringWriter() + composeHtmlToStream(writer, chunkSize = 1024, key = key) { + TagElement("script", null) { Text(text) } + } + assertEquals("", writer.toString()) + assertNotSame(renderer, assertNotNull(getHtmlRendererPool(key)).borrow()) + } + + private fun seedRenderer(key: String): HtmlRenderer { + val pool = assertNotNull(getHtmlRendererPool(key)) + return pool.borrow().also(pool::recycle) + } + + private fun render(renderer: HtmlRenderer, text: String): String = + renderer.render(false, false, null, 2048, "") { Text(text) } +} diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HydratedDocumentToWriterTest.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HydratedDocumentToWriterTest.kt index 0e31fee808..ed4978102a 100644 --- a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HydratedDocumentToWriterTest.kt +++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HydratedDocumentToWriterTest.kt @@ -16,6 +16,8 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertSame import kotlin.test.assertTrue @@ -73,16 +75,22 @@ class HydratedDocumentToWriterTest { } } } + val key = "document-writer-reuse" + val pool = assertNotNull(getHtmlRendererPool(key)) + val renderer = pool.borrow() + pool.recycle(renderer) for (strict in listOf(false, true)) { for (size in listOf(1, 4, 32, 2048, 4096, Int.MAX_VALUE)) { val writer = RecordingWriter() val strings = mutableListOf() - renderHydratedDocumentToStream(strings::add, size, strict, content) - renderHydratedDocumentToStream(writer, size, strict, content) + renderHydratedDocumentToStream(strings::add, size, strict, key = key, content = content) + renderHydratedDocumentToStream(writer, size, strict, key = key, content = content) assertEquals(strings, writer.chunks) assertEquals(renderHydratedDocument(strict, content), writer.chunks.joinToString("")) assertEquals(0, writer.flushes) assertEquals(0, writer.closes) + assertSame(renderer, pool.borrow()) + pool.recycle(renderer) } } } @@ -108,6 +116,10 @@ class HydratedDocumentToWriterTest { @Test fun propagatesWriterFailuresDuringCompositionAndCanRenderAgain() { + val key = "document-writer-failure" + val pool = assertNotNull(getHtmlRendererPool(key)) + val renderer = pool.borrow() + pool.recycle(renderer) val failure = IOException("write failed") val writer = object : Writer() { override fun write(chars: CharArray, offset: Int, length: Int) { throw failure } @@ -116,13 +128,16 @@ class HydratedDocumentToWriterTest { } var childrenComposed = false assertSame(failure, assertFailsWith { - renderHydratedDocumentToStream(writer, chunkSize = 1) { + renderHydratedDocumentToStream(writer, chunkSize = 1, key = key) { Html { childrenComposed = true } } }) assertFalse(childrenComposed) + val replacement = pool.borrow() + assertNotSame(renderer, replacement) + pool.recycle(replacement) val nextWriter = RecordingWriter() - renderHydratedDocumentToStream(nextWriter) { Html { Body { Text("next") } } } + renderHydratedDocumentToStream(nextWriter, key = key) { Html { Body { Text("next") } } } assertEquals("next", nextWriter.chunks.single()) } } diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToWriterTest.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToWriterTest.kt index 0b5481091a..7e3f6958a0 100644 --- a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToWriterTest.kt +++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToWriterTest.kt @@ -18,6 +18,7 @@ import org.jetbrains.compose.web.htmlWriterSink import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue class ComposeHtmlToWriterTest { @@ -84,9 +85,17 @@ class ComposeHtmlToWriterTest { assertTrue(writer.chunks.isEmpty()) assertEquals(0, writer.flushes) assertEquals(0, writer.closes) - for (size in listOf(0, -1)) assertFailsWith { - composeHtmlToStream(writer, chunkSize = size) {} + var composed = false + for (size in listOf(0, -1)) { + val failure = assertFailsWith { + composeHtmlToStream(writer, chunkSize = size) { composed = true; Text("unused") } + } + assertEquals("chunkSize must be positive", failure.message) } + assertFalse(composed) + assertTrue(writer.chunks.isEmpty()) + assertEquals(0, writer.flushes) + assertEquals(0, writer.closes) } @Test @@ -169,5 +178,15 @@ class ComposeHtmlToWriterTest { assertTrue(renderer.bufferCapacity >= large.length) render("small", RecordingWriter()) assertTrue(renderer.bufferCapacity <= 65536) + + render(large) + val chunk = "x".repeat(20 * 1024) + val writer = RecordingWriter() + render(chunk, writer) + assertEquals(listOf(chunk), writer.chunks) + assertEquals(chunk.length, renderer.bufferCapacity) + assertEquals(0, renderer.bufferLength) + render(chunk, RecordingWriter()) + assertEquals(chunk.length, renderer.bufferCapacity) } } diff --git a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.web.kt b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.web.kt new file mode 100644 index 0000000000..fe4acbfa3a --- /dev/null +++ b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.web.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web + +// JS and Wasm/JS execute synchronously on one thread per runtime, including in workers. +// No synchronization needed. +internal actual class HtmlRendererPools actual constructor( + private val maxKeys: Int, + private val maxIdleRenderers: Int, +) { + private val pools = mutableMapOf() + + actual operator fun get(key: String): HtmlRendererPool? = pools[key] ?: run { + if (pools.size >= maxKeys) null + else HtmlRendererPool(maxIdleRenderers).also { pools[key] = it } + } +} + +internal actual class HtmlRendererPool actual constructor(private val maxIdleRenderers: Int) { + private val available = ArrayDeque() + + actual fun borrow(): HtmlRenderer = available.removeFirstOrNull() ?: HtmlRenderer() + + actual fun recycle(renderer: HtmlRenderer) { + if (renderer.bufferCapacity > MAX_RETAINED_BUFFER_CAPACITY) return + if (available.size < maxIdleRenderers) available.addLast(renderer) + } +} diff --git a/html/settings.gradle.kts b/html/settings.gradle.kts index 2ee02bb9be..0594e00e6f 100644 --- a/html/settings.gradle.kts +++ b/html/settings.gradle.kts @@ -102,6 +102,7 @@ module(":html-test-utils", "test-utils") if (composeHtmlEapEnabled) { println("Compose HTML EAP modules enabled") module(":html-core-eap", "eap/core") + module(":html-eap-benchmarks", "eap/benchmarks") module(":html-svg-eap", "eap/svg") module(":internal-html-core-runtime-eap", "eap/internal-html-core-runtime") module(":html-eap-example", "eap/examples/string-rendering") From 05b8829004e2c31cbce17b5a60aa271ea7b2ec72 Mon Sep 17 00:00:00 2001 From: Clemens Koprolin Date: Fri, 9 Oct 2026 12:20:05 +0200 Subject: [PATCH 2/3] Make Compose HTML renderer reuse safe for concurrent calls Pool renderers by key --- .../jetbrains/compose/web/HtmlRendererPool.kt | 15 ++++++++------- .../compose/web/HtmlRendererPool.jvm.kt | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.kt index 0c13c8ea14..4275a67546 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.kt @@ -5,22 +5,23 @@ package org.jetbrains.compose.web -// Buffer policy uses the platform's reported capacity in UTF-16 code units. -// JVM reports backing capacity. JS and Wasm/JS report logical length, so after clearing -// the output their capacity is zero and the retention ceiling does not constrain backing storage. +// Buffer limits use UTF-16 code units. JVM capacity measures allocated buffer space; +// JS and Wasm/JS report text length, so the limit cannot bound retained buffer memory there. internal const val LARGE_BUFFER_THRESHOLD = 64 * 1024 internal const val BUFFER_SHRINK_FACTOR = 4 internal const val MAX_RETAINED_BUFFER_CAPACITY = 2 * 1024 * 1024 -/** Bounded registry without eviction. Empty pools retain their key slots; unregistered keys render uncached. */ +/** + * Stores a limited number of keys and does not remove them. + * Once full, new keys render without pooling. + */ internal expect class HtmlRendererPools(maxKeys: Int, maxIdleRenderers: Int) { operator fun get(key: String): HtmlRendererPool? } /** - * Borrowers own their storage exclusively and recycle it only after successful rendering. - * Output buffers with reported capacity above [MAX_RETAINED_BUFFER_CAPACITY] are not retained. - * On JS and Wasm/JS, capacity is logical length, which is zero when recycling a cleared renderer. + * Each active render owns its storage. Only successful renders return it to the pool. + * Keeps at most [maxIdleRenderers] idle renderers; oversized JVM buffers are discarded. */ internal expect class HtmlRendererPool(maxIdleRenderers: Int) { fun borrow(): HtmlRenderer diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.jvm.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.jvm.kt index 06ac4686ba..a24356595d 100644 --- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.jvm.kt +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HtmlRendererPool.jvm.kt @@ -15,28 +15,35 @@ internal actual class HtmlRendererPools actual constructor( private val maxIdleRenderers: Int, ) { private val pools = ConcurrentHashMap() + + // Includes registrations in progress, not just pools already in the map. private val keyCount = AtomicInteger() actual operator fun get(key: String): HtmlRendererPool? = pools[key] ?: register(key) private fun register(key: String): HtmlRendererPool? { - // Avoid counter writes when capacity is already reserved; a racing insert may have published this key. + // Avoid updating the counter when all slots are already reserved. if (keyCount.get() >= maxKeys) return pools[key] - // Reserve capacity before insertion: ConcurrentHashMap.size cannot enforce a concurrent cap. + + // Reserve a slot before insertion to enforce the limit under concurrency. if (keyCount.incrementAndGet() > maxKeys) { keyCount.decrementAndGet() return pools[key] } + val pool = HtmlRendererPool(maxIdleRenderers) val existing = pools.putIfAbsent(key, pool) + + // Another thread registered the same key, so release our reservation. if (existing != null) keyCount.decrementAndGet() + return existing ?: pool } } internal actual class HtmlRendererPool actual constructor(private val maxIdleRenderers: Int) { - // The first slot keeps the usual single-renderer path short. Overflow slots bound total - // retention without queue nodes or an idle counter. All publication transfers ownership. + // Optimize for a single idle renderer. Additional slots bound retention + // without queue allocations or a separate idle counter. private val first = AtomicReference() private val overflow = AtomicReferenceArray((maxIdleRenderers - 1).coerceAtLeast(0)) @@ -45,6 +52,8 @@ internal actual class HtmlRendererPool actual constructor(private val maxIdleRen private fun borrowOverflow(): HtmlRenderer { for (index in 0 until overflow.length()) { val renderer = overflow.get(index) + + // Claim the renderer only if another thread hasn't taken it. if (renderer != null && overflow.compareAndSet(index, renderer, null)) return renderer } return HtmlRenderer() @@ -59,5 +68,6 @@ internal actual class HtmlRendererPool actual constructor(private val maxIdleRen for (index in 0 until overflow.length()) { if (overflow.compareAndSet(index, null, renderer)) return } + // All slots are occupied; let the renderer be garbage-collected. } } From bcf201a81b01944e02cb213610dffc9c989349de Mon Sep 17 00:00:00 2001 From: Clemens Koprolin Date: Fri, 9 Oct 2026 14:17:42 +0200 Subject: [PATCH 3/3] Migrate renderer pool benchmarks to Kotlin Simplify nullable renderer pool lookup, correct Kdocs --- html/eap/benchmarks/README.md | 16 ++-- html/eap/benchmarks/build.gradle.kts | 51 ++++++++-- .../compose/web/RendererPoolBenchmark.java | 94 ------------------- .../compose/web/RendererPoolBenchmark.kt | 88 +++++++++++++++++ .../compose/web/ComposeHtmlToStream.kt | 2 +- .../compose/web/ComposeHtmlToString.kt | 8 +- .../jetbrains/compose/web/HydratedDocument.kt | 6 +- .../compose/web/ComposeHtmlToStream.kt | 2 +- .../jetbrains/compose/web/HydratedDocument.kt | 4 +- 9 files changed, 148 insertions(+), 123 deletions(-) delete mode 100644 html/eap/benchmarks/src/main/java/org/jetbrains/compose/web/RendererPoolBenchmark.java create mode 100644 html/eap/benchmarks/src/main/kotlin/org/jetbrains/compose/web/RendererPoolBenchmark.kt diff --git a/html/eap/benchmarks/README.md b/html/eap/benchmarks/README.md index f09cb240b0..0d0a1315df 100644 --- a/html/eap/benchmarks/README.md +++ b/html/eap/benchmarks/README.md @@ -14,20 +14,20 @@ From the repository root, run (default 4 workers): ```shell html/gradlew -p html -Pcompose.html.eap.enabled=true \ - :html-eap-benchmarks:run + :html-eap-benchmarks:benchmark ``` -For a single-worker baseline: +Defaults: 1 or 64 rows, two JVM forks, two one-second warmups, and three one-second measurements. +Results are saved under `html/eap/benchmarks/build/reports/benchmarks`. + +For a short smoke test of all strategies and row counts: ```shell html/gradlew -p html -Pcompose.html.eap.enabled=true \ - :html-eap-benchmarks:run --args='-prof gc -t 1' + :html-eap-benchmarks:smokeBenchmark ``` -Defaults: 1 or 64 rows, two JVM forks, two one-second warmups, and three one-second measurements. -GC profiling reports allocation. - ## Add a benchmark -Add a Java class under `src/main/java` with JMH `@Benchmark` methods. -Gradle discovers it automatically. +Add an `open` Kotlin class under `src/main/kotlin` with `kotlinx.benchmark` annotations. +Use `@JvmField` on `@Param` properties so JMH can populate them. diff --git a/html/eap/benchmarks/build.gradle.kts b/html/eap/benchmarks/build.gradle.kts index f33294ee74..5d7c31941b 100644 --- a/html/eap/benchmarks/build.gradle.kts +++ b/html/eap/benchmarks/build.gradle.kts @@ -1,28 +1,59 @@ +import kotlinx.benchmark.gradle.JvmBenchmarkTarget +import org.gradle.jvm.tasks.Jar +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { - application + kotlin("jvm") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlinx.benchmark") version "0.4.15" } val composeVersion = providers.gradleProperty("compose.version").get() val browserVersion = providers.gradleProperty("compose.html.eap.kotlinx-browser-common-subset.version").get() -val jmhVersion = "1.37" +val benchmarkVersion = "0.4.15" dependencies { implementation(project(":html-core-eap")) implementation("org.jetbrains.compose.runtime:runtime:$composeVersion") implementation("org.jetbrains.compose.html:kotlinx-browser-common-subset:$browserVersion") - implementation("org.openjdk.jmh:jmh-core:$jmhVersion") - annotationProcessor("org.openjdk.jmh:jmh-generator-annprocess:$jmhVersion") + implementation("org.jetbrains.kotlinx:kotlinx-benchmark-runtime:$benchmarkVersion") +} + +// Allow the benchmark to access renderer internals. +tasks.named("compileKotlin") { + friendPaths.from( + project(":html-core-eap").tasks.named("jvmJar") + .flatMap { it.archiveFile } + ) } // Match the library's Java compatibility. -tasks.withType().configureEach { - options.release.set(11) +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } -application { - mainClass.set("org.openjdk.jmh.Main") +tasks.withType().configureEach { + options.release.set(11) } -tasks.named("run") { - args("-prof", "gc") +benchmark { + configurations { + configureEach { + // Preserve @Fork rather than the runner's default of one fork. + advanced("jvmForks", "definedByJmh") + } + register("smoke") { + warmups = 1 + iterations = 1 + iterationTime = 100 + iterationTimeUnit = "ms" + } + } + targets { + register("main") { + this as JvmBenchmarkTarget + jmhVersion = "1.37" + } + } } diff --git a/html/eap/benchmarks/src/main/java/org/jetbrains/compose/web/RendererPoolBenchmark.java b/html/eap/benchmarks/src/main/java/org/jetbrains/compose/web/RendererPoolBenchmark.java deleted file mode 100644 index 24b8d04b8f..0000000000 --- a/html/eap/benchmarks/src/main/java/org/jetbrains/compose/web/RendererPoolBenchmark.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. - * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. - */ - -package org.jetbrains.compose.web; - -import androidx.compose.runtime.Composer; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import kotlin.Unit; -import kotlin.jvm.functions.Function2; -import org.jetbrains.compose.web.dom.ElementsKt; -import org.openjdk.jmh.annotations.*; - -/** Compares renderer reuse strategies with the same HTML workload on concurrent workers. */ -@State(Scope.Benchmark) -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(TimeUnit.MICROSECONDS) -@Warmup(iterations = 2, time = 1) -@Measurement(iterations = 3, time = 1) -@Fork(value = 2, jvmArgsAppend = {"-Xms256m", "-Xmx256m"}) -@Threads(4) -public class RendererPoolBenchmark { - @Param({"1", "64"}) - public int rows; - - // Keep the previous cache shape for a direct before/after comparison. - private final ThreadLocal> threadLocal = - ThreadLocal.withInitial(HashMap::new); - private Function2 content; - - @Setup - public void setup() { - content = (composer, changed) -> { - for (int row = 0; row < rows; row++) { - ElementsKt.Div(null, (scope, childComposer, childChanged) -> { - ElementsKt.Text("row <&>\uD83D\uDE00", childComposer, 0); - ElementsKt.Span(null, (childScope, textComposer, textChanged) -> { - ElementsKt.Text("tail", textComposer, 0); - return Unit.INSTANCE; - }, childComposer, 0, 1); - return Unit.INSTANCE; - }, composer, 0, 1); - } - return Unit.INSTANCE; - }; - // Check identical output for every strategy before measuring performance. - String expected = "
row <&>\uD83D\uDE00tail
".repeat(rows); - check(expected, fresh()); - check(expected, threadLocal()); - check(expected, sharedKeyedPool()); - } - - // Allocation baseline: each call owns a new renderer. - @Benchmark - public String fresh() { - return render(new HtmlRenderer()); - } - - // Each worker reuses its own renderer. - @Benchmark - public String threadLocal() { - Map cache = threadLocal.get(); - HtmlRenderer renderer = cache.get("template"); - if (renderer == null) { - renderer = new HtmlRenderer(); - cache.put("template", renderer); - } - return render(renderer); - } - - // Workers borrow exclusive renderers from the same keyed pool. - @Benchmark - public String sharedKeyedPool() { - HtmlRendererPool pool = ComposeHtmlToStringKt.getHtmlRendererPool("benchmark-template"); - HtmlRenderer renderer = pool.borrow(); - String result = render(renderer); - pool.recycle(renderer); - return result; - } - - private String render(HtmlRenderer renderer) { - // Use the same rendering path in every case, excluding public validation wrappers. - return renderer.render(true, false, null, 2048, "", content); - } - - private static void check(String expected, String actual) { - if (!expected.equals(actual)) { - throw new AssertionError("Every storage strategy must render the expected HTML"); - } - } -} diff --git a/html/eap/benchmarks/src/main/kotlin/org/jetbrains/compose/web/RendererPoolBenchmark.kt b/html/eap/benchmarks/src/main/kotlin/org/jetbrains/compose/web/RendererPoolBenchmark.kt new file mode 100644 index 0000000000..56c42fad1a --- /dev/null +++ b/html/eap/benchmarks/src/main/kotlin/org/jetbrains/compose/web/RendererPoolBenchmark.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web + +import androidx.compose.runtime.Composable +import kotlinx.benchmark.* +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Threads + +/** Compares renderer reuse strategies with the same HTML workload on concurrent workers. */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(BenchmarkTimeUnit.MICROSECONDS) +@Warmup(iterations = 2, time = 1) +@Measurement(iterations = 3, time = 1) +@Fork(value = 2, jvmArgsAppend = ["-Xms256m", "-Xmx256m"]) +@Threads(4) +open class RendererPoolBenchmark { + @Param("1", "64") + @JvmField + var rows: Int = 0 + + // Keep the previous cache shape for a direct before/after comparison. + private val threadLocal = ThreadLocal.withInitial { HashMap() } + private lateinit var content: @Composable () -> Unit + + @Setup + open fun setup() { + content = { + repeat(rows) { + Div { + Text("row <&>😀") + Span { Text("tail") } + } + } + } + // Check identical output for every strategy before measuring performance. + val expected = "
row <&>😀tail
".repeat(rows) + check(expected, fresh()) + check(expected, threadLocal()) + check(expected, sharedKeyedPool()) + } + + // Allocation baseline: each call owns a new renderer. + @Benchmark + open fun fresh(): String = render(HtmlRenderer()) + + // Each worker reuses its own renderer. + @Benchmark + open fun threadLocal(): String { + val renderer = threadLocal.get().getOrPut("template") { HtmlRenderer() } + return render(renderer) + } + + // Workers borrow exclusive renderers from the same keyed pool. + @Benchmark + open fun sharedKeyedPool(): String { + val pool = checkNotNull(getHtmlRendererPool("benchmark-template")) + val renderer = pool.borrow() + val result = render(renderer) + pool.recycle(renderer) + return result + } + + private fun render(renderer: HtmlRenderer): String { + // Use the same rendering path in every case, excluding public validation wrappers. + return renderer.render( + hydratable = true, + requireHtmlDocumentRoot = false, + chunkSink = null, + chunkSize = 2048, + prefix = "", + content = content, + ) + } + + private fun check(expected: String, actual: String) { + if (expected != actual) { + throw AssertionError("Every storage strategy must render the expected HTML") + } + } +} 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 054b27d743..67184dff4b 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt @@ -15,7 +15,7 @@ import androidx.compose.runtime.Composable * calls stay intact, as do elements whose contents require validation. A chunk can exceed [chunkSize], * and no empty chunks are sent. * - * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. * * [sink] runs synchronously while rendering and may block. Output already sent cannot be retracted * if rendering or the sink throws. Failed rendering storage is discarded. 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 24a4c48ae9..11a8d7789e 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 @@ -21,7 +21,7 @@ import org.jetbrains.compose.web.dom.LocalComposeHtmlContext * Snapshot state changes are discarded after rendering. Coroutine effects such as * `LaunchedEffect` do not run. `SideEffect` and `DisposableEffect` still execute. * - * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. * See [composeHtmlToStream] for validation and failure handling. * * Inline styles preserve CSS fallbacks but do not fully emulate CSSOM validation and mutation. @@ -59,8 +59,8 @@ internal fun composeHtmlString( content: @Composable () -> Unit, ): String { if (chunkSink != null) require(chunkSize > 0) { "chunkSize must be positive" } - - val pool = key?.let(::getHtmlRendererPool) + + val pool = getHtmlRendererPool(key) val renderer = pool?.borrow() ?: HtmlRenderer() // Return storage to the pool only after rendering, effect cleanup and snapshot disposal succeed. @@ -71,7 +71,7 @@ internal fun composeHtmlString( private val renderers = HtmlRendererPools(maxKeys = 64, maxIdleRenderers = 8) -internal fun getHtmlRendererPool(key: String): HtmlRendererPool? = renderers[key] +internal fun getHtmlRendererPool(key: String?): HtmlRendererPool? = if (key == null) null else renderers[key] /** Storage exclusively owned by one active render, then optionally returned to a pool. */ internal class HtmlRenderer { 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 b4d5e039c3..2cb1202d37 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 @@ -50,7 +50,7 @@ fun renderHydratedDocument( /** * Renders a complete document with fresh storage. * - * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. * See [composeHtmlToStream] for validation and failure handling. */ fun renderHydratedDocument( @@ -67,7 +67,7 @@ fun renderHydratedDocument( * may exceed the target. [sink] runs synchronously, may block, and receives no empty chunks. * Emitted output cannot be retracted on failure, including document validation after composition. * - * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. * See [composeHtmlToStream] for validation and failure handling. * * @param key when non-null, enables pooled rendering storage across calls and threads. @@ -89,7 +89,7 @@ fun renderHydratedDocumentToStream( /** * Streams a complete document with fresh storage. * - * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. * See [composeHtmlToStream] for validation and failure handling. */ fun renderHydratedDocumentToStream( 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 68ddfda66c..e1b4741a5f 100644 --- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt @@ -18,7 +18,7 @@ import java.io.Writer * intact. Elements requiring content validation are emitted only after validation succeeds. * A write can exceed [chunkSize]. No empty writes are made. * - * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. * See [composeHtmlToStream] for validation and failure handling. * * Output already written cannot be retracted on failure, and failed rendering storage is discarded. diff --git a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt index 96d9ba8de1..e8f123460e 100644 --- a/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt @@ -19,7 +19,7 @@ import java.io.Writer * consume each write before returning because the character array is reused. The writer is not flushed * or closed. Output already written cannot be retracted if rendering, validation or writing throws. * - * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. * See [composeHtmlToStream] for validation and failure handling. * * @param key when non-null, enables pooled rendering storage across calls and threads. @@ -43,7 +43,7 @@ fun renderHydratedDocumentToStream( /** * Streams a complete document with fresh storage. * - * Calls are thread-safe. Callers must Synchronize shared mutable data accessed by [content]. + * Calls are thread-safe. Synchronize shared mutable data accessed by [content]. * See [composeHtmlToStream] for validation and failure handling. */ fun renderHydratedDocumentToStream(