From 51eefedac4e7764405324aaa060052f749a64e78 Mon Sep 17 00:00:00 2001 From: Clemens Koprolin Date: Mon, 5 Oct 2026 18:36:33 +0200 Subject: [PATCH] Use single-pass composition for Compose HTML rendering Make a single-pass composer the default for string, streaming and hydrated-document rendering on JVM, JS and Wasm. Add a limited movable-content support. --- html/eap/README.md | 2 +- .../compose/web/ComposeHtmlToStream.kt | 22 +- .../compose/web/ComposeHtmlToString.kt | 187 ++++---- .../jetbrains/compose/web/HydratedDocument.kt | 7 +- .../compose/web/SinglePassComposer.kt | 246 +++++++++++ .../compose/web/HydratedDocumentStreamTest.kt | 21 + .../compose/web/SinglePassComposerTest.kt | 126 ++++++ .../compose/web/SinglePassRenderingTest.kt | 401 ++++++++++++++++++ .../web/dom/DirectStringRenderingTest.kt | 30 +- .../compose/web/ComposeHtmlToStream.kt | 14 +- .../web/dom/ComposeHtmlToWriterTest.kt | 40 +- 11 files changed, 924 insertions(+), 172 deletions(-) create mode 100644 html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/SinglePassComposer.kt create mode 100644 html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassComposerTest.kt create mode 100644 html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt diff --git a/html/eap/README.md b/html/eap/README.md index 23e17e51f7..3b5af9c260 100644 --- a/html/eap/README.md +++ b/html/eap/README.md @@ -83,4 +83,4 @@ The page stays static except for three hydration roots. From the repository root ```shell html/gradlew -p html -Pcompose.html.eap.enabled=true \ :html-eap-hydration-islands-example:wasmJsBrowserDevelopmentRun -``` \ No newline at end of file +``` 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 677398cdab..551b7765f2 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 @@ -17,18 +17,20 @@ import androidx.compose.runtime.CompositionLocalProvider * for `pre`, `textarea`, and `listing` happens before their first content is emitted. * A chunk can exceed [chunkSize], and no empty chunks are sent. * - * With no [key], the composition is disposed after rendering. A key retains the composition and - * attribute-builder storage for later calls to either rendering function. 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. + * 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. * * [sink] runs synchronously while rendering and may block. Output already sent cannot be retracted - * if rendering or the sink throws. A failed keyed render is disposed before the exception is rethrown. + * if rendering or the sink throws. Failed rendering storage is reset before the exception is rethrown. * * @param hydratable whether to emit text-boundary markers needed to hydrate adjacent `Text` calls. * @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 another render with the same non-null [key] is in progress. + * @throws IllegalStateException if rendering reenters with the same non-null [key], or [content] + * accesses `currentRecomposeScope`. + * @throws UnsupportedOperationException if [content] uses an unsupported composition operation. */ fun composeHtmlToStream( sink: (String) -> Unit, @@ -45,9 +47,7 @@ fun composeHtmlToStream( content() } } - if (key == null) { - composeHtmlString(hydratable = hydratable, content = validatedContent, chunkSink = chunkSink, chunkSize = chunkSize) - } else { - composeReusableHtmlString(key, hydratable, content = validatedContent, chunkSink = chunkSink, chunkSize = chunkSize) - } + composeHtmlString( + hydratable = hydratable, key = key, chunkSink = chunkSink, chunkSize = chunkSize, content = validatedContent, + ) } 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 a3f3ab8e4b..ed6dfced65 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 @@ -3,25 +3,23 @@ * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. */ +@file:OptIn(androidx.compose.runtime.InternalComposeApi::class) + package org.jetbrains.compose.web -import androidx.compose.runtime.AbstractApplier import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composer import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.ControlledComposition -import androidx.compose.runtime.Recomposer -import androidx.compose.runtime.ReusableComposition import androidx.compose.runtime.snapshots.Snapshot -import kotlinx.coroutines.Dispatchers import org.jetbrains.compose.web.dom.AttrsBuilderPool import org.jetbrains.compose.web.dom.HtmlStringWriterContext import org.jetbrains.compose.web.dom.LocalComposeHtmlContext /** * Composes [content] once into an HTML string without creating browser DOM nodes. - * With no [key], the backing composition is disposed after the HTML has been written. - * With a key, the composition and attribute-builder storage are retained for later calls with - * that key. Keyed calls are not thread-safe, even with different keys. + * 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. @@ -37,12 +35,14 @@ import org.jetbrains.compose.web.dom.LocalComposeHtmlContext * * @param hydratable whether to emit text-boundary markers required to hydrate adjacent `Text` * nodes. Set to `false` when the output will not be hydrated. - * @param key when non-null, reuses a composition across calls. + * @param key when non-null, reuses rendering storage across calls. * @param validateStrictly overrides the default strict-validation setting for this render. * @throws IllegalArgumentException if a raw-text element contains unsafe text or element children, * serialized `noscript` contents contain a `` end tag, or ordinary text, RCDATA, * or attribute values contain NUL (U+0000), which HTML parsing cannot preserve. - * @throws IllegalStateException if another render with the same non-null [key] is in progress. + * @throws IllegalStateException if rendering reenters with the same non-null [key], or [content] + * accesses `currentRecomposeScope`. + * @throws UnsupportedOperationException if [content] uses an unsupported composition operation. */ fun composeHtmlToString( hydratable: Boolean = true, @@ -55,124 +55,87 @@ fun composeHtmlToString( content() } } - return if (key == null) { - composeHtmlString(hydratable = hydratable, content = validatedContent) - } else { - composeReusableHtmlString(key, hydratable, content = validatedContent) - } -} - -private const val LARGE_BUFFER_THRESHOLD = 64 * 1024 -private const val BUFFER_SHRINK_FACTOR = 4 - -private val reusableHtmlCompositions = mutableMapOf() - -private class ReusableHtmlComposition { - val recomposer = cancelledRecomposer() - val composition = ReusableComposition(UnitApplier(), recomposer) - val attrsBuilders = AttrsBuilderPool() - val output = StringBuilder() - var content: (@Composable () -> Unit)? = null - var rendering = false - - fun dispose() { - composition.dispose() - recomposer.close() - } + return composeHtmlString(hydratable = hydratable, key = key, content = validatedContent) } -/** Reuses a keyed composition and its attribute builders across string and streaming renders. */ -internal fun composeReusableHtmlString( - key: String, - hydratable: Boolean, - chunkSink: ((StringBuilder) -> Unit)? = null, - chunkSize: Int = 2048, - content: @Composable () -> Unit, -): String { - val renderer = reusableHtmlCompositions.getOrPut(key, ::ReusableHtmlComposition) - check(!renderer.rendering) { "HTML render key \"$key\" is already rendering" } - - renderer.output.setLength(0) // Discard output left by the previous render. - val context = HtmlStringWriterContext(hydratable, renderer.attrsBuilders, renderer.output, chunkSink, chunkSize) - val snapshot = Snapshot.takeMutableSnapshot() - renderer.rendering = true - renderer.content = content - try { - return snapshot.enter { - try { - renderer.composition.setContentWithReuse { - CompositionLocalProvider(LocalComposeHtmlContext provides context) { - checkNotNull(renderer.content).invoke() - } - } - context.finish(requireHtmlDocumentRoot = false).also { - // Keep capacity for large renders. Trim after much smaller ones. - if (isOversized(renderer.output.capacity(), context.peakBufferSize)) { - renderer.output.trimToSize() - } - } - } finally { - renderer.composition.deactivate() - } - } - } catch (failure: Throwable) { - reusableHtmlCompositions.remove(key) - renderer.dispose() - throw failure - } finally { - renderer.content = null - renderer.rendering = false - snapshot.dispose() - } -} - -private fun isOversized(capacity: Int, used: Int): Boolean = - capacity > LARGE_BUFFER_THRESHOLD && used.toLong() * BUFFER_SHRINK_FACTOR < capacity - -/** Runs a fresh composition with a buffer shared by string and streaming output. */ +/** Shared single-pass composition and writer for fragments, documents, and streaming output. */ internal fun composeHtmlString( hydratable: Boolean = true, requireHtmlDocumentRoot: Boolean = false, chunkSink: ((StringBuilder) -> Unit)? = null, chunkSize: Int = 2048, prefix: String = "", + key: String? = null, content: @Composable () -> Unit, ): String { - val context = HtmlStringWriterContext( - hydratable, output = StringBuilder(prefix), chunkSink = chunkSink, chunkSize = chunkSize, - ) - val snapshot = Snapshot.takeMutableSnapshot() + val renderer = if (key == null) HtmlRenderer(reusable = false) else renderers.getOrPut(key) { HtmlRenderer() } + return renderer.render(hydratable, requireHtmlDocumentRoot, chunkSink, chunkSize, prefix, content) +} - return try { - snapshot.enter { - val recomposer = cancelledRecomposer() - val composition = ControlledComposition(applier = UnitApplier(), parent = recomposer) +private const val LARGE_BUFFER_THRESHOLD = 64 * 1024 +private const val BUFFER_SHRINK_FACTOR = 4 +private val renderers = mutableMapOf() + +/** 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() + private var output = StringBuilder() + private var composer = SinglePassComposer() + private var rendering = false + + internal val bufferCapacity: Int get() = output.capacity() - try { - composition.setContent { - CompositionLocalProvider(LocalComposeHtmlContext provides context) { - content() + fun render( + hydratable: Boolean, + requireHtmlDocumentRoot: Boolean, + chunkSink: ((StringBuilder) -> Unit)?, + chunkSize: Int, + 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 + try { + val wrapped: @Composable () -> Unit = { + CompositionLocalProvider(LocalComposeHtmlContext provides context) { content() } } + @Suppress("UNCHECKED_CAST") + (wrapped as (Composer, Int) -> Unit)(composer, 1) + composer.applyEffects() + context.finish(requireHtmlDocumentRoot).also { + if (isOversized(output.capacity(), context.peakBufferSize)) { + output.trimToSize() + } + } + } catch (cause: Throwable) { + failure = cause + throw cause + } finally { + composer.dispose(failure) } - context.finish(requireHtmlDocumentRoot) - } finally { - composition.dispose() - recomposer.close() } + } 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() } - } finally { - snapshot.dispose() } } -/** Composes the initial content without starting coroutine effects. */ -private fun cancelledRecomposer() = Recomposer(Dispatchers.Default).apply { cancel() } - -/** [HtmlStringWriterContext] emits no Compose nodes, but Composition still requires an applier. */ -private class UnitApplier : AbstractApplier(Unit) { - override fun insertTopDown(index: Int, instance: Unit) = Unit - override fun insertBottomUp(index: Int, instance: Unit) = Unit - override fun remove(index: Int, count: Int) = Unit - override fun move(from: Int, to: Int, count: Int) = Unit - override fun onClear() = Unit -} +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/HydratedDocument.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt index dceb21d88d..81a0025373 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 @@ -1,3 +1,8 @@ +/* + * 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 @@ -43,7 +48,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. * - * Each call disposes its composition. Effects, snapshots, and buffering follow [composeHtmlToStream]. + * Each call releases its remembered state. Effects, snapshots, and buffering follow [composeHtmlToStream]. * * @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. diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/SinglePassComposer.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/SinglePassComposer.kt new file mode 100644 index 0000000000..4bba2c7375 --- /dev/null +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/SinglePassComposer.kt @@ -0,0 +1,246 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +@file:OptIn(InternalComposeApi::class) +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "SEALED_INHERITOR_IN_DIFFERENT_MODULE", "SEALED_INHERITOR_IN_DIFFERENT_PACKAGE") + +package org.jetbrains.compose.web + +import androidx.compose.runtime.* +import androidx.compose.runtime.tooling.CompositionData +import androidx.compose.runtime.tooling.CompositionGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import org.jetbrains.compose.web.internal.unsafeCast + +/** + * An insertion-only interpreter for the compiler protocol. Values never need to be compared or + * retained because there is no second composition. Only local-provider scope, group position, + * and effect callbacks survive a call. + */ +internal class SinglePassComposer : Composer { + override val inserting get() = true + override val skipping get() = false + override val defaultsInvalid get() = false + override val recomposeScope: RecomposeScope? get() = null + override val recomposeScopeIdentity: Any? get() = null + override fun shouldExecute(parametersChanged: Boolean, flags: Int) = true + + // Primitive stacks retain only the active path, not every group in the rendered document. + private var children = IntArray(64) + private var hashes = LongArray(64) + private var depth = 0 + private var locals = emptyCompositionLocalMap + private val providers = ArrayList() + private val observers = ArrayList() + private val effects = ArrayList<() -> Unit>() + private var rememberedCount = 0 + + private fun enter(key: Int, keyed: Boolean = false) { + if (depth + 1 == children.size) { + val newSize = children.size * 2 + children = children.copyOf(newSize) + hashes = hashes.copyOf(newSize) + } + val ordinal = if (keyed) 0 else children[depth]++ + val parentHash = if (depth == 0) 0L else hashes[depth - 1] + hashes[depth] = (parentHash.rotateLeft(3) xor key.toLong()).rotateLeft(3) xor ordinal.toLong() + children[++depth] = 0 + } + + private fun leave() { + check(depth > 0) + depth-- + } + + override val currentMarker get() = depth + override fun endToMarker(marker: Int) { + require(marker in 0..depth) + depth = marker + } + + override val compositeKeyHashCode: CompositeKeyHashCode + // The runtime uses Long on JVM, JS, and Wasm, but exposes an expect class to common code. + get() = (if (depth == 0) 0L else hashes[depth - 1]).unsafeCast() + + /** Resets mutable state for reuse across keyed renders. Arrays retain their grown capacity. */ + fun reset() { + depth = 0 + children[0] = 0 + locals = emptyCompositionLocalMap + providers.clear() + observers.clear() + effects.clear() + rememberedCount = 0 + } + + override fun startReplaceableGroup(key: Int) = enter(key) + override fun endReplaceableGroup() = leave() + override fun startReplaceGroup(key: Int) = enter(key) + override fun endReplaceGroup() = leave() + override fun startRestartGroup(key: Int): Composer { + enter(key) + return this + } + + override fun endRestartGroup(): ScopeUpdateScope? { + leave() + return null + } + + override fun startMovableGroup(key: Int, dataKey: Any?) = + enter(if (dataKey is Enum<*>) dataKey.ordinal else dataKey?.hashCode() ?: key, dataKey != null) + override fun endMovableGroup() = leave() + override fun startReusableGroup(key: Int, dataKey: Any?) = + enter(if (key == reuseKey && dataKey != null && dataKey !== Composer.Empty) dataKey.hashCode() else key) + override fun endReusableGroup() = leave() + override fun startDefaults() = enter(-127) + override fun endDefaults() = leave() + override fun disableReusing() {} + override fun enableReusing() {} + override fun deactivateToEndGroup(changed: Boolean) {} + override fun skipToGroupEnd() = unsupported("skipping") + override fun skipCurrentGroup() = unsupported("skipping") + override fun joinKey(left: Any?, right: Any?): Any = JoinedKey(left, right) + + override fun rememberedValue(): Any = Composer.Empty + override fun changed(value: Any?) = true + override fun changedInstance(value: Any?) = true + override fun changed(value: Boolean) = true + override fun changed(value: Char) = true + override fun changed(value: Byte) = true + override fun changed(value: Short) = true + override fun changed(value: Int) = true + override fun changed(value: Float) = true + override fun changed(value: Long) = true + override fun changed(value: Double) = true + + override val currentCompositionLocalMap: CompositionLocalMap get() = locals + override fun consume(key: CompositionLocal): T = locals.read(key) + override fun startProviders(values: Array>) { + providers.add(locals) + locals = locals.builder().apply { putAll(updateCompositionMap(values, locals)) }.build() + } + override fun endProviders() { + locals = providers.removeAt(providers.lastIndex) + } + override fun startProvider(value: ProvidedValue<*>) = startProviders(arrayOf(value)) + override fun endProvider() = endProviders() + + override fun updateRememberedValue(value: Any?) { + if (value is RememberObserver) observers.add(value) + } + override fun recordSideEffect(effect: () -> Unit) { + effects.add(effect) + } + + fun applyEffects() { + check(depth == 0 && providers.isEmpty()) { "Unbalanced single-pass composition" } + while (rememberedCount < observers.size) { + observers[rememberedCount++].onRemembered() + } + for (effect in effects) effect() + } + + fun dispose(originalFailure: Throwable?) { + var failure = originalFailure + fun cleanup(block: () -> Unit) { + try { + block() + } catch (cause: Throwable) { + if (failure == null) { + failure = cause + } else if (failure !== cause) { + failure!!.addSuppressed(cause) + } + } + } + try { + val remembered = if (rememberedCount == observers.size) { + emptySet() + } else { + HashSet().apply { + for (i in 0 until rememberedCount) { + add(RememberObserverIdentity(observers[i])) + } + } + } + // Once onRemembered is reached (even if it throws), forget every occurrence in reverse location order. + // Include occurrences after the failed notification, and never abandon that instance. + for (i in observers.lastIndex downTo 0) { + val observer = observers[i] + if (i < rememberedCount || RememberObserverIdentity(observer) in remembered) { + cleanup { observer.onForgotten() } + } + } + val abandoned = HashSet() + for (i in rememberedCount until observers.size) { + val observer = observers[i] + val identity = RememberObserverIdentity(observer) + if (identity !in remembered && abandoned.add(identity)) { + cleanup { observer.onAbandoned() } + } + } + if (originalFailure == null) failure?.let { throw it } + } finally { + // Retain storage capacity, but release request objects even when cleanup throws. + reset() + } + } + override val applyCoroutineContext get() = cancelledEffectContext + + override fun recordUsed(scope: RecomposeScope) {} + override fun sourceInformation(sourceInformation: String) {} + override fun sourceInformationMarkerStart(key: Int, sourceInformation: String) {} + override fun sourceInformationMarkerEnd() {} + override fun collectParameterInformation() {} + override fun disableSourceInformation() {} + override val compositionData: CompositionData get() = EmptyCompositionData + override fun scheduleFrameEndCallback(action: () -> Unit) = CancellationHandle {} + + override val applier: Applier<*> get() = unsupported("custom Compose nodes") + override fun startNode(): Unit = unsupported("custom Compose nodes") + override fun startReusableNode(): Unit = unsupported("custom Compose nodes") + override fun createNode(factory: () -> T): Unit = unsupported("custom Compose nodes") + override fun useNode(): Unit = unsupported("custom Compose nodes") + override fun endNode(): Unit = unsupported("custom Compose nodes") + override fun apply(value: V, block: T.(V) -> Unit): Unit = unsupported("custom Compose nodes") + override fun insertMovableContent(value: MovableContent<*>, parameter: Any?) { + // SSR has no previous state to move. Render at the invocation site with its current locals. + startMovableGroup(movableContentKey, value) + hashes[depth - 1] = movableContentKey.toLong() + try { + @Suppress("UNCHECKED_CAST") + val content: @Composable () -> Unit = { (value as MovableContent).content(parameter) } + @Suppress("UNCHECKED_CAST") + (content as (Composer, Int) -> Unit)(this, 1) + } finally { + endMovableGroup() + } + } + override fun insertMovableContentReferences(references: List>): Unit = unsupported("movable content state transfer") + override fun buildContext(): CompositionContext = unsupported("subcomposition") + override val composition: ControlledComposition get() = unsupported("controlled composition access") + private fun unsupported(feature: String): Nothing = throw UnsupportedOperationException( + "Single-pass HTML rendering does not support $feature" + ) +} + +private fun Long.rotateLeft(bitCount: Int): Long = + (this shl bitCount) or (this ushr (Long.SIZE_BITS - bitCount)) + +private class RememberObserverIdentity(private val observer: RememberObserver) { + override fun equals(other: Any?): Boolean = + other is RememberObserverIdentity && observer === other.observer + + override fun hashCode(): Int = androidx.compose.runtime.internal.identityHashCode(observer) +} + +private val emptyCompositionLocalMap = CompositionLocalMap.Empty.unsafeCast() +private val cancelledEffectContext by lazy { Dispatchers.Unconfined + Job().apply { cancel() } } +private object EmptyCompositionData : CompositionData { + override val compositionGroups: Iterable get() = emptyList() + override val isEmpty get() = true +} diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentStreamTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentStreamTest.kt index af12ca34b7..0a2f5c00a3 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentStreamTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentStreamTest.kt @@ -7,7 +7,9 @@ package org.jetbrains.compose.web import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.movableContentOf 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.Head @@ -23,6 +25,25 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class HydratedDocumentStreamTest { + @Test + fun documentRenderersUseSinglePassMovableContentWithFreshRememberedState() { + var remembered = 0 + val content: @Composable () -> Unit = { + val movable = remember { + movableContentOf { Div { Text("render ${remember { ++remembered }}") } } + } + Html { Body { movable() } } + } + assertEquals( + "
render 1
", + renderHydratedDocument(content = content), + ) + val chunks = mutableListOf() + renderHydratedDocumentToStream(chunks::add, chunkSize = 1, content = content) + assertEquals("
render 2
", chunks.joinToString("")) + assertEquals(2, remembered) + } + @Test fun defaultChunkSizeEmitsAt2048CodeUnitsDuringComposition() { val text = "x".repeat(2048) diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassComposerTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassComposerTest.kt new file mode 100644 index 0000000000..86e273c52d --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassComposerTest.kt @@ -0,0 +1,126 @@ +/* + * 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.RememberObserver +import kotlin.test.* + +class SinglePassComposerTest { + @Test + fun disposalReleasesObserversAndSideEffects() { + val composer = SinglePassComposer() + val events = mutableListOf() + composer.updateRememberedValue(observer(events)) + composer.recordSideEffect { events.add("effect") } + composer.applyEffects() + composer.dispose(null) + + // Running the lifecycle again must not redispatch callbacks from the disposed request. + composer.applyEffects() + composer.dispose(null) + assertEquals(listOf("remember", "effect", "forget"), events) + } + + @Test + fun failedCleanupStillReleasesObserversAndSideEffects() { + val composer = SinglePassComposer() + val events = mutableListOf() + val failure = IllegalStateException("cleanup failure") + composer.updateRememberedValue(observer(events, failure)) + composer.recordSideEffect { events.add("effect") } + composer.applyEffects() + assertSame(failure, assertFailsWith { composer.dispose(null) }) + + composer.applyEffects() + composer.dispose(null) + assertEquals(listOf("remember", "effect", "forget"), events) + } + + @Test + fun abandonedCompositionReleasesObserversAndUnappliedEffects() { + val composer = SinglePassComposer() + val events = mutableListOf() + composer.updateRememberedValue(observer(events)) + composer.recordSideEffect { events.add("effect") } + composer.dispose(IllegalStateException("composition failure")) + + composer.applyEffects() + composer.dispose(null) + assertEquals(listOf("abandon"), events) + } + + @Test + fun partialRememberFailurePreservesSharedObserverLifecycle() { + val composer = SinglePassComposer() + val events = mutableListOf() + val original = IllegalStateException("remember failure") + fun observer(name: String, fails: Boolean = false) = object : RememberObserver { + override fun onRemembered() { + events.add("remember $name") + if (fails) throw original + } + override fun onForgotten() { + events.add("forget $name") + } + override fun onAbandoned() { + events.add("abandon $name") + } + override fun equals(other: Any?) = other is RememberObserver + override fun hashCode(): Int = error("cleanup must use identity") + } + val shared = observer("shared") + val failing = observer("failing", fails = true) + val unreached = observer("unreached") + for (observer in listOf(shared, failing, shared, failing, unreached, unreached)) { + composer.updateRememberedValue(observer) + } + composer.recordSideEffect { error("failed remembering must not run side effects") } + + assertSame(original, assertFailsWith { composer.applyEffects() }) + composer.dispose(original) + assertEquals( + listOf( + "remember shared", "remember failing", + "forget failing", "forget shared", "forget failing", "forget shared", + "abandon unreached", + ), + events, + ) + composer.applyEffects() + composer.dispose(null) + } + + @Test + fun sharedObserversAreForgottenPerLocationAndAbandonedOnce() { + for (fail in listOf(false, true)) { + val composer = SinglePassComposer() + val events = mutableListOf() + val shared = observer(events) + repeat(2) { composer.updateRememberedValue(shared) } + if (fail) { + composer.dispose(IllegalStateException("composition failure")) + assertEquals(listOf("abandon"), events) + } else { + composer.applyEffects() + composer.dispose(null) + assertEquals(listOf("remember", "remember", "forget", "forget"), events) + } + } + } + + private fun observer(events: MutableList, failure: Throwable? = null) = object : RememberObserver { + override fun onRemembered() { + events.add("remember") + } + override fun onForgotten() { + events.add("forget") + if (failure != null) throw failure + } + override fun onAbandoned() { + events.add("abandon") + } + } +} 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 new file mode 100644 index 0000000000..f24a5c08a6 --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SinglePassRenderingTest.kt @@ -0,0 +1,401 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ +@file:OptIn(org.jetbrains.compose.web.internal.runtime.ComposeWebInternalApi::class) + +package org.jetbrains.compose.web + +import androidx.compose.runtime.* +import kotlinx.browser.dom.Element +import kotlinx.coroutines.Dispatchers +import org.jetbrains.compose.web.dom.* +import kotlin.test.* + +class SinglePassRenderingTest { + @Test + fun changingKeyedRequestsMatchFreshRendersAcrossModesAndNamespaces() { + for (strict in listOf(false, true)) for (hydratable in listOf(false, true)) { + for (request in listOf(0, 1, 7, 2, 0, 5, 1)) { + val content: @Composable () -> Unit = { + Div(attrs = { if (request % 2 == 0) classes("one", "two"); attr("data-x", "<&\"é😀") }) { + for (item in (0..request).reversed()) key(item) { + TagElement(if (item % 2 == 0) "span" else "strong", null) { + Text("$item &<>\ré😀"); Text(""); Text("tail") + } + } + TagElement("textarea", null) { Text("\nfirst"); Text("second") } + TagElement("script", null) { Text("const x = '<&';") } + TagElement("noscript", null) { Span { Text("fallback") } } + TagElementNS("svg", "http://www.w3.org/2000/svg", { attr("viewBox", "0 0 1 1") }, null) + } + } + val expected = composeHtmlToString(hydratable, validateStrictly = strict, content = content) + assertEquals(expected, composeHtmlToString(hydratable, "changing", strict, content)) + } + } + } + + @Test + fun streamingMatchesStringRendering() { + val chunks = mutableListOf() + composeHtmlToStream(sink = chunks::add, chunkSize = 8, key = "common-stream") { + Div { Text("portable <&> output") } + } + assertEquals( + composeHtmlToString(key = "common-string") { Div { Text("portable <&> output") } }, + chunks.joinToString(""), + ) + assertTrue(chunks.size > 1) + } + + @Test + fun nestedComputedDefaultAndNullableLocalsRestoreTheirScopes() { + val number = compositionLocalOf { 2 } + val twice = compositionLocalWithComputedDefaultOf { number.currentValue * 2 } + val nullable = staticCompositionLocalOf { "default" } + val content: @Composable () -> Unit = { + Text("${number.current}/${twice.current}/${nullable.current};") + CompositionLocalProvider(number provides 3, nullable provides null) { + Text("${number.current}/${twice.current}/${nullable.current};") + CompositionLocalProvider(number providesDefault 99, twice providesComputed { number.currentValue * 10 }) { + Text("${number.current}/${twice.current};") + CompositionLocalProvider(number provides 7) { Text("${twice.current};") } + } + Text("${twice.current};") + } + Text("${number.current}/${twice.current}/${nullable.current}") + } + assertEquals( + "2/4/default;3/6/null;3/30;70;6;2/4/default", + composeHtmlToString(content = content), + ) + } + + @Test + fun sameLambdaFreshRememberAndSnapshotIsolation() { + val state = mutableStateOf("outside") + var value = "" + var calls = 0 + val content: @Composable () -> Unit = { + val remembered = remember { ++calls } + state.value = value + Div { Text("${state.value}:$remembered") } + } + for ((i, next) in listOf("😀<&".repeat(10_000), "short", "", "next").withIndex()) { + value = next + val expected = composeHtmlToString { Div { Text("$value:${i + 1}") } } + assertEquals(expected, composeHtmlToString(key = "same-lambda", content = content)) + assertEquals("outside", state.value) + } + assertEquals(4, calls) + } + + @Test + fun observersAndEffectsPreserveLifecycleOrdering() { + val events = mutableListOf() + composeHtmlToString { + remember { Observer("outer", events) } + SideEffect { events.add("side outer") } + Div { + remember { Observer("inner", events) } + DisposableEffect(Unit) { events.add("effect"); onDispose { events.add("dispose") } } + SideEffect { events.add("side inner") } + } + } + assertEquals( + listOf( + "remember outer", "remember inner", "effect", "side outer", "side inner", + "dispose", "forget inner", "forget outer", + ), + events, + ) + } + + @Test + fun failedCompositionAbandonsAndPreservesOriginalException() { + val events = mutableListOf() + val original = IllegalArgumentException("user failure") + val cleanup = IllegalStateException("cleanup failure") + val thrown = assertFailsWith { + composeHtmlToString(key = "failed-composition") { + remember { Observer("one", events) } + remember { object : RememberObserver { + override fun onRemembered() { error("should not run") } + override fun onForgotten() { error("should not run") } + override fun onAbandoned() { throw cleanup } + } } + SideEffect { error("should not run") } + throw original + } + } + assertSame(original, thrown) + assertTrue(cleanup in thrown.suppressedExceptions) + assertEquals(listOf("abandon one"), events) + assertEquals("

recovered

", composeHtmlToString(key = "failed-composition") { P { Text("recovered") } }) + } + + @Test + fun failedSideEffectStillDisposesEveryObserver() { + val events = mutableListOf() + assertFailsWith { + composeHtmlToString { + remember { Observer("one", events) } + remember { Observer("two", events) } + SideEffect { error("side effect failure") } + } + } + assertEquals(listOf("remember one", "remember two", "forget two", "forget one"), events) + } + + @Test + fun unsupportedOperationsFailExplicitlyWithoutRetryingUserCode() { + var executions = 0 + assertFailsWith { + composeHtmlToString { executions++; rememberCompositionContext() } + } + assertEquals(1, executions) + assertFailsWith { composeHtmlToString { currentCompositionLocalContext } } + assertFailsWith { composeHtmlToString { currentRecomposeScope } } + assertFailsWith { composeHtmlToStream(sink = {}) { currentRecomposeScope } } + } + + @Test + fun rememberedMovableContentRendersInEitherParent() { + for (column in listOf(true, false, true)) for (stream in listOf(false, true)) { + for (renderKey in listOf(null, "remembered-movable")) { + var creations = 0 + var bodyExecutions = 0 + val content: @Composable () -> Unit = { + val someContent = remember { + creations++ + movableContentOf { + bodyExecutions++ + Div { Text("Text in a Movable Div") } + } + } + P { Text("before") } + // Separate call sites model the Column/Row branches without requiring + // Compose UI layout components in a Compose HTML test. + if (column) { + Div(attrs = { classes("column") }) { someContent() } + } else { + Div(attrs = { classes("row") }) { someContent() } + } + P { Text("after") } + } + val html = if (stream) { + val chunks = mutableListOf() + composeHtmlToStream(sink = chunks::add, chunkSize = 8, key = renderKey, content = content) + assertTrue(chunks.size > 1) + chunks.joinToString("") + } else { + composeHtmlToString(key = renderKey, content = content) + } + val parentClass = if (column) "column" else "row" + assertEquals( + "

before

Text in a Movable Div

after

", + html, + ) + assertEquals(1, creations) + assertEquals(1, bodyExecutions) + } + } + } + + @Test + fun rememberingMovableContentWithoutInvokingItDoesNotFail() { + var creations = 0 + val html = composeHtmlToString { + remember { + creations++ + movableContentOf { error("unused movable content must not execute") } + } + Div { Text("ordinary content") } + } + assertEquals("
ordinary content
", html) + assertEquals(1, creations) + } + + @Test + fun nestedParameterizedMovableContentPreservesLocalsAndEffectLifecycle() { + val local = staticCompositionLocalOf { "default" } + fun render(movable: Boolean): Pair> { + val events = mutableListOf() + var remembers = 0 + val content: @Composable () -> Unit = { + val leaf: @Composable (String) -> Unit = { value -> + val number = remember { ++remembers } + val label = "${local.current}:$value:$number" + remember { Observer(label, events) } + DisposableEffect(Unit) { + events.add("effect $label") + onDispose { events.add("dispose $label") } + } + SideEffect { events.add("side $label") } + Span { Text(label); Text("<&") } + } + val child = if (movable) remember { movableContentOf(leaf) } else leaf + val parent: @Composable (String, Int) -> Unit = { value, count -> + Div { repeat(count) { child(value) } } + } + val outer = if (movable) remember { movableContentOf(parent) } else parent + CompositionLocalProvider(local provides "first") { outer("A", 2) } + CompositionLocalProvider(local provides "second") { outer("B", 1) } + P { Text(local.current) } + } + val html = composeHtmlToString(content = content) + assertEquals(3, remembers) + return html to events + } + assertEquals(render(movable = false), render(movable = true)) + } + + @Test + fun movableContentSupportsReceiverAndNullableParameters() { + val receiver = movableContentWithReceiverOf { Text(this) } + val nullable = movableContentOf { Text(it ?: "null") } + assertEquals("
receivernull
", composeHtmlToString { + Div { receiver("receiver"); nullable(null) } + }) + } + + @Test + fun failedMovableContentAbandonsAndAllowsRecovery() { + val events = mutableListOf() + val original = IllegalArgumentException("movable failure") + val movable = movableContentOf { + remember { Observer("movable", events) } + SideEffect { error("failed content must not run effects") } + Div { throw original } + } + assertSame(original, assertFailsWith { + composeHtmlToString(key = "failed-movable") { Div { movable() } } + }) + assertEquals(listOf("abandon movable"), events) + assertEquals("

recovered

", composeHtmlToString(key = "failed-movable") { + P { Text("recovered") } + }) + } + + @Test + fun movableContentHashesIgnoreParentsAndRestoreOuterHash() { + val hashes = mutableListOf() + val movable = movableContentOf { hashes.add(currentCompositeKeyHashCode.toLong()) } + composeHtmlToString { + val outerHash = currentCompositeKeyHashCode + Div { movable() } + assertEquals(outerHash, currentCompositeKeyHashCode) + P { movable() } + assertEquals(outerHash, currentCompositeKeyHashCode) + } + assertEquals(2, hashes.size) + assertEquals(hashes[0], hashes[1]) + } + + @Test + fun compositeHashesAreStableAndDistinguishKeyedChildren() { + fun hashes(order: List): Map { + val result = mutableMapOf() + composeHtmlToString { + for (item in order) key(item) { result[item] = currentCompositeKeyHashCode.toLong() } + } + return result + } + val first = hashes(listOf(1, 2, 3)) + assertEquals(3, first.values.toSet().size) + assertEquals(first, hashes(listOf(3, 2, 1))) + } + + @Test + @OptIn(InternalComposeApi::class) + fun compositeHashesMatchRuntimeKeyRules() { + fun hashes(composer: Composer): List { + val hashes = mutableListOf() + val parent = composer.compositeKeyHashCode.toLong() + // Remove the runtime's own root groups to compare the same child groups. + val parentContribution = (parent shl 6) or (parent ushr 58) + fun record() { + hashes.add(composer.compositeKeyHashCode.toLong() xor parentContribution) + } + val keys = listOf( + null, HashKey.First, HashKey.Second, "key", 7, + composer.joinKey(HashKey.Second, HashKey.First), + composer.joinKey(composer.joinKey(HashKey.First, 7), HashKey.Second), + ) + for (key in keys) { + composer.startMovableGroup(91, key) + record() + composer.endMovableGroup() + composer.startReplaceGroup(92) + record() + composer.endReplaceGroup() + } + for (key in listOf(null, 7, HashKey.Second, Composer.Empty)) { + composer.startReusableGroup(207, key) + record() + composer.endReusableGroup() + } + composer.startReusableGroup(93, "auxiliary") + record() + composer.endReusableGroup() + assertEquals(parent, composer.compositeKeyHashCode.toLong()) + return hashes + } + val recomposer = Recomposer(Dispatchers.Unconfined) + val composition = ControlledComposition(object : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) {} + override fun insertBottomUp(index: Int, instance: Unit) {} + override fun remove(index: Int, count: Int) {} + override fun move(from: Int, to: Int, count: Int) {} + override fun onClear() {} + }, recomposer) + try { + var expected = emptyList() + composition.setContent { expected = hashes(currentComposer) } + assertEquals(expected, hashes(SinglePassComposer())) + } finally { + composition.dispose() + recomposer.cancel() + } + } + + @Test + fun compositeHashesRemainStableAcrossRepeatedKeyedRenders() { + val content: @Composable () -> Unit = { + Text(currentCompositeKeyHashCode.toLong().toString()) + for (item in listOf(1, 2, 3)) key(item) { + Div { Text(currentCompositeKeyHashCode.toLong().toString()) } + } + } + val expected = composeHtmlToString(key = "stable-request-hashes", content = content) + repeat(5) { + assertEquals(expected, composeHtmlToString(key = "stable-request-hashes", content = content)) + } + } + + @Test + fun nestedRenderRestoresContextAndSameKeyReentryIsRejected() { + assertEquals("
outer<span>inner</span>tail
", composeHtmlToString(key = "outer") { + Div { + Text("outer") + Text(composeHtmlToString(key = "inner") { Span { Text("inner") } }) + Text("tail") + } + }) + assertFailsWith { + composeHtmlToString(key = "reentrant") { + composeHtmlToString(key = "reentrant") { Text("bad") } + } + } + assertEquals("ok", composeHtmlToString(key = "reentrant") { Text("ok") }) + } + + private enum class HashKey { First, Second } + + private class Observer(val name: String, val events: MutableList) : RememberObserver { + override fun onRemembered() { events.add("remember $name") } + override fun onForgotten() { events.add("forget $name") } + override fun onAbandoned() { events.add("abandon $name") } + } +} diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/DirectStringRenderingTest.kt index 25a127db21..7dab813931 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 @@ -110,12 +110,11 @@ class DirectStringRenderingTest { } @Test - fun movableContentHasTheExistingOneShotRendererLimitation() { + fun movableContentRendersInline() { val movable = movableContentOf { Span { Text("moved") } } val content: @Composable () -> Unit = { Div { movable() }; P { Text("after") } } - // The cancelled SSR recomposer has never inserted deferred movable content. - assertEquals("

after

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

after

", composeHtmlToString(key = "movable", content = content)) + assertEquals("
moved

after

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

after

", composeHtmlToString(key = "movable", content = content)) } @Test @@ -135,22 +134,15 @@ class DirectStringRenderingTest { } @Test - fun capturedCompositionLocalsAndCompositionContextsRemainSupported() { - val language = staticCompositionLocalOf { "en" } - val content: @Composable () -> Unit = { - CompositionLocalProvider(language provides "de") { - val captured = currentCompositionLocalContext - rememberCompositionContext() - CompositionLocalProvider(language provides "fr") { - CompositionLocalProvider(captured) { - P { Text(language.current) } - } - } + fun compositionContextCaptureFailsExplicitly() { + for (renderKey in listOf(null, "captured-locals")) { + assertFailsWith { + composeHtmlToString(key = renderKey) { currentCompositionLocalContext } } - } - assertEquals("

de

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

de

", composeHtmlToString(key = "captured-locals", content = content)) + assertFailsWith { + composeHtmlToString(key = renderKey) { rememberCompositionContext() } + } + assertEquals("

recovered

", composeHtmlToString(key = renderKey) { P { Text("recovered") } }) } } 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 0088d60a64..5e02f64867 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 @@ -19,9 +19,9 @@ 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. * - * Composition reuse, effects, validation, and failure handling follow [composeHtmlToStream] - * with a String sink. Keyed renders are not thread-safe. Output already written cannot be - * retracted on failure, and a failed keyed composition is disposed. + * 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. */ fun composeHtmlToStream( writer: Writer, @@ -38,11 +38,9 @@ fun composeHtmlToStream( content() } } - if (key == null) { - composeHtmlString(hydratable = hydratable, chunkSize = chunkSize, chunkSink = chunkSink, content = validatedContent) - } else { - composeReusableHtmlString(key, hydratable, chunkSize = chunkSize, chunkSink = chunkSink, content = validatedContent) - } + composeHtmlString( + hydratable = hydratable, key = key, chunkSink = chunkSink, chunkSize = chunkSize, content = validatedContent, + ) } /** Creates a Writer sink that reuses character storage across chunks within one render. */ 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 6d9f82668e..0b5481091a 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 @@ -11,8 +11,10 @@ import java.io.IOException import java.io.OutputStreamWriter import java.io.Writer import kotlinx.browser.dom.Element +import org.jetbrains.compose.web.HtmlRenderer import org.jetbrains.compose.web.composeHtmlToStream import org.jetbrains.compose.web.composeHtmlToString +import org.jetbrains.compose.web.htmlWriterSink import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -146,28 +148,26 @@ class ComposeHtmlToWriterTest { @Test fun shrinksOldLargeBuffersAfterSmallRendersButReusesRepeatedLargeBuffers() { - val key = "writer-buffer-capacity" + val renderer = HtmlRenderer() + fun render(text: String, writer: Writer? = null): String = renderer.render( + hydratable = false, + requireHtmlDocumentRoot = false, + chunkSink = writer?.let(::htmlWriterSink), + chunkSize = 2048, + prefix = "", + ) { Text(text) } val large = "α".repeat(1024 * 1024) - composeHtmlToString(key = key) { Text(large) } - val output = keyedOutput(key) - val largeCapacity = output.capacity() + render(large) + val largeCapacity = renderer.bufferCapacity assertTrue(largeCapacity >= large.length) - composeHtmlToString(key = key) { Text(large) } - assertEquals(largeCapacity, output.capacity()) - assertEquals("small", composeHtmlToString(key = key) { Text("small") }) - assertTrue(output.capacity() <= 65536) + render(large) + assertEquals(largeCapacity, renderer.bufferCapacity) + assertEquals("small", render("small")) + assertTrue(renderer.bufferCapacity <= 65536) - composeHtmlToString(key = key) { Text(large) } - assertTrue(output.capacity() >= large.length) - composeHtmlToStream(RecordingWriter(), key = key) { Text("small") } - assertTrue(output.capacity() <= 65536) - } - - private fun keyedOutput(key: String): StringBuilder { - val field = Class.forName("org.jetbrains.compose.web.ComposeHtmlToStringKt") - .getDeclaredField("reusableHtmlCompositions").apply { isAccessible = true } - val renderer = (field.get(null) as Map<*, *>)[key]!! - val output = renderer.javaClass.getDeclaredField("output").apply { isAccessible = true } - return output.get(renderer) as StringBuilder + render(large) + assertTrue(renderer.bufferCapacity >= large.length) + render("small", RecordingWriter()) + assertTrue(renderer.bufferCapacity <= 65536) } }