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 new file mode 100644 index 0000000000..677398cdab --- /dev/null +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt @@ -0,0 +1,53 @@ +/* + * 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 + +/** + * 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. + * + * 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. + * + * [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. + * + * @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. + */ +fun composeHtmlToStream( + sink: (String) -> Unit, + chunkSize: Int = 2048, + hydratable: Boolean = true, + key: String? = null, + 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() + } + } + if (key == null) { + composeHtmlString(hydratable = hydratable, content = validatedContent, chunkSink = chunkSink, chunkSize = chunkSize) + } else { + composeReusableHtmlString(key, hydratable, content = validatedContent, chunkSink = chunkSink, chunkSize = chunkSize) + } +} 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 361d9cb23f..a3f3ab8e4b 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 @@ -58,10 +58,13 @@ fun composeHtmlToString( return if (key == null) { composeHtmlString(hydratable = hydratable, content = validatedContent) } else { - composeReusableHtmlString(key, hydratable, validatedContent) + 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 { @@ -78,16 +81,19 @@ private class ReusableHtmlComposition { } } -private fun composeReusableHtmlString( +/** 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) { "composeHtmlToString key \"$key\" is already rendering" } + check(!renderer.rendering) { "HTML render key \"$key\" is already rendering" } - renderer.output.setLength(0) // empty string output before next render - val context = HtmlStringWriterContext(hydratable, renderer.attrsBuilders, renderer.output) + 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 @@ -99,7 +105,12 @@ private fun composeReusableHtmlString( checkNotNull(renderer.content).invoke() } } - context.finish(requireHtmlDocumentRoot = false) + 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() } @@ -115,13 +126,21 @@ private fun composeReusableHtmlString( } } -/** Runs a fresh composition while the context writes markup directly to its output buffer. */ +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. */ internal fun composeHtmlString( hydratable: Boolean = true, requireHtmlDocumentRoot: Boolean = false, + chunkSink: ((StringBuilder) -> Unit)? = null, + chunkSize: Int = 2048, + prefix: String = "", content: @Composable () -> Unit, ): String { - val context = HtmlStringWriterContext(hydratable) + val context = HtmlStringWriterContext( + hydratable, output = StringBuilder(prefix), chunkSink = chunkSink, chunkSize = chunkSize, + ) val snapshot = Snapshot.takeMutableSnapshot() return try { 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 c3d20e3ada..dceb21d88d 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 @@ -27,13 +27,51 @@ private const val HtmlDoctype = "" * * 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. */ fun renderHydratedDocument( validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, +): String = composeHydratedDocument(validateStrictly = validateStrictly, content = content) + +/** + * Streams the same HTML as [renderHydratedDocument], including its doctype and hydration state. + * [content] must produce exactly one `html` element; client loading requirements follow + * [renderHydratedDocument]. + * + * [chunkSize] defaults to 2048 UTF-16 code units. Tags and `Text` calls stay intact, so chunks + * may exceed the target. [sink] runs synchronously, may block, and receives no empty chunks. + * Emitted output cannot be retracted on failure, including document validation after composition. + * + * Each call disposes its composition. 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. + */ +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) +} + +/** Shared document context and protocol for complete-string and streaming output. */ +internal fun composeHydratedDocument( + validateStrictly: Boolean, + chunkSink: ((StringBuilder) -> Unit)? = null, + chunkSize: Int = 2048, + content: @Composable () -> Unit, ): String { val seenHydrationIds = if (validateStrictly) mutableSetOf() else null - return HtmlDoctype + composeHtmlString(requireHtmlDocumentRoot = true) { + return composeHtmlString( + requireHtmlDocumentRoot = true, + chunkSink = chunkSink, + chunkSize = chunkSize, + prefix = HtmlDoctype, + ) { CompositionLocalProvider( LocalHydratedDocumentContext provides true, LocalHydrationIds provides seenHydrationIds, @@ -59,7 +97,7 @@ fun renderHydratedDocument( * that do not need client-side reconciliation. * * DOM references and element effects used through the content receiver are available after - * browser hydration, but throw during server string rendering. + * browser hydration, but throw during server rendering. */ @Composable fun HydrationRoot( @@ -70,7 +108,7 @@ fun HydrationRoot( content: @Composable ElementScope.(T) -> Unit, ) { check(LocalHydratedDocumentContext.current) { - "HydrationRoot must be called inside renderHydratedDocument" + "HydrationRoot must be called inside renderHydratedDocument or renderHydratedDocumentToStream" } require(hydrationId == null || hydrationId.isNotBlank()) { "hydrationId must not be blank" diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt index b05d11532c..06353208ba 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/dom/Base.kt @@ -166,9 +166,13 @@ internal class RawTextContent private constructor( ) { fun validateAttributes(attributes: Map) { if (tagName == "script") { - require(attributes.keys.none { it.equals("src", ignoreCase = true) }) { - "Inline \r\u0000" + val content: @Composable () -> Unit = { + Html { + Head { Title { Text("Streamed ") } } + Body { + HydrationRoot(state, { it }, rootAttrs = { classes("application") }, hydrationId = "application") { + Text("first<&>") + Text("adjacent๐Ÿ˜€") + } + HydrationRoot(42, { it.toString() }, hydrationId = "counter") { Text(it.toString()) } + } + } + } + for (strict in listOf(false, true)) { + val expected = renderHydratedDocument(strict, content) + for (size in listOf(1, 4, 32, 2048, 4096, Int.MAX_VALUE)) { + val chunks = mutableListOf() + renderHydratedDocumentToStream(chunks::add, size, strict, content) + val rendered = chunks.joinToString("") + assertEquals(expected, rendered) + assertTrue(rendered.startsWith("")) + assertTrue(chunks.all { it.isNotEmpty() }) + assertContains(rendered, "first<&>adjacent๐Ÿ˜€") + assertContains(rendered, "<value>&lt;</script> �</value>") + assertEquals(strict, HydrationValidationAttribute in rendered) + } + } + } + + @Test + fun duplicateNamedRootsFailOnlyInStrictMode() { + val content: @Composable () -> Unit = { + Html { + Body { + HydrationRoot(Unit, { "first" }, hydrationId = "cart") {} + HydrationRoot(Unit, { "second" }, hydrationId = "cart") {} + } + } + } + val failure = assertFailsWith { + renderHydratedDocumentToStream({}, chunkSize = 1, validateStrictly = true, content = content) + } + assertContains(failure.message.orEmpty(), "Duplicate Compose hydrationId \"cart\"") + + val chunks = mutableListOf() + renderHydratedDocumentToStream(chunks::add, chunkSize = 1, validateStrictly = false, content = content) + assertEquals(renderHydratedDocument(validateStrictly = false, content = content), chunks.joinToString("")) + } + + @Test + fun emitsBeforeCompositionFinishesAndSerializesEachStateExactlyOnceBeforeItsContent() { + val chunks = mutableListOf() + val calls = mutableListOf() + renderHydratedDocumentToStream(chunks::add, chunkSize = 1) { + Html { + Body { + HydrationRoot("snapshot", { + calls += "serialize:$it" + it + }) { + calls += "content:$it" + Text(it) + assertTrue(chunks.joinToString("").endsWith("snapshot")) + } + assertTrue(chunks.joinToString("").endsWith("snapshot")) + } + } + } + assertEquals(listOf("serialize:snapshot", "content:snapshot"), calls) + assertTrue(chunks.size > 1) + assertEquals("", chunks.first()) + } + + @Test + fun staticDocumentsDoNotShipHydrationStateAndLargeTargetsProduceOneChunk() { + val chunks = mutableListOf() + renderHydratedDocumentToStream(chunks::add, chunkSize = Int.MAX_VALUE) { + Html { Body { Text("Static page") } } + } + assertEquals(listOf("Static page"), chunks) + assertFalse(HydrationStateAttribute in chunks.single()) + } + + @Test + fun validatesDocumentShapeInBothModesIncludingWhenOutputHasAlreadyBeenSent() { + val invalidDocuments = listOf<@Composable () -> Unit>( + {}, + { Text("fragment") }, + { Div() }, + { Html(); Html() }, + { Html(); Text("trailing text") }, + ) + for (strict in listOf(false, true)) { + for (size in listOf(1, Int.MAX_VALUE)) { + for (content in invalidDocuments) { + val chunks = mutableListOf() + val failure = assertFailsWith { + renderHydratedDocumentToStream(chunks::add, size, strict, content) + } + assertContains(failure.message.orEmpty(), "exactly one html element") + if (size == Int.MAX_VALUE) assertTrue(chunks.isEmpty()) + } + } + } + val partial = mutableListOf() + assertFailsWith { + renderHydratedDocumentToStream(partial::add, chunkSize = 1) { Div() } + } + assertEquals("
", partial.joinToString("")) + } + + @Test + fun strictModeValidatesContentAndTheDefaultModeIsTransported() { + val invalidClasses: @Composable () -> Unit = { + Html { Body { HydrationRoot(Unit, { "null" }) { Div({ classes("two words") }) } } } + } + assertFailsWith { + renderHydratedDocumentToStream({}, validateStrictly = true, content = invalidClasses) + } + renderHydratedDocumentToStream({}, validateStrictly = false, content = invalidClasses) + + val chunks = mutableListOf() + renderHydratedDocumentToStream(chunks::add) { + Html { Body { HydrationRoot(Unit, { "null" }) {} } } + } + assertEquals( + defaultHtmlValidationMode() == HtmlValidationMode.Strict, + HydrationValidationAttribute in chunks.joinToString(""), + ) + } + + @Test + fun rejectsInvalidChunkSizesBeforeComposingOrEmitting() { + val chunks = mutableListOf() + var composed = false + for (size in listOf(0, -1)) { + assertFailsWith { + renderHydratedDocumentToStream(chunks::add, size) { composed = true; Html() } + } + } + assertFalse(composed) + assertTrue(chunks.isEmpty()) + } + + @Test + fun discardsSnapshotChangesAndDisposesTheCompositionEvenWhenTheSinkFails() { + for (fail in listOf(false, true)) { + val sharedState = mutableStateOf("initial") + var disposals = 0 + val sinkFailure = IllegalStateException("sink failed") + val chunks = mutableListOf() + val render = { + renderHydratedDocumentToStream( + sink = { if (fail) throw sinkFailure else chunks.add(it) }, + chunkSize = Int.MAX_VALUE, + ) { + DisposableEffect(Unit) { onDispose { disposals++ } } + sharedState.value = "rendered" + Html { Body { HydrationRoot(sharedState.value, { it }) { Text(it) } } } + } + } + if (fail) assertSame(sinkFailure, assertFailsWith { render() }) + else render() + assertEquals("initial", sharedState.value) + assertEquals(1, disposals) + if (!fail) assertContains(chunks.single(), "rendered") + } + } + + @Test + fun rejectsHydrationRootOutsideTheDocumentRenderer() { + assertFailsWith { + composeHtmlToStream({}) { HydrationRoot(Unit, { "null" }) {} } + } + } +} diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SsrHydrationStateFixture.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SsrHydrationStateFixture.kt index 796fbcf55a..709749ab5b 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SsrHydrationStateFixture.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SsrHydrationStateFixture.kt @@ -11,6 +11,8 @@ import org.jetbrains.compose.web.dom.Text // Shared declarations for the JVM-rendered fixture and the JS hydration test. internal const val SSR_HYDRATION_STATE_FIXTURE_URL = "./ssr-hydration-state.html" +internal const val SSR_HYDRATION_STATE_STREAM_FAST_FIXTURE_URL = "./ssr-hydration-state-stream-fast.html" +internal const val SSR_HYDRATION_STATE_STREAM_STRICT_FIXTURE_URL = "./ssr-hydration-state-stream-strict.html" internal const val SSR_HYDRATION_STATE_BUTTON_ID = "ssr-hydration-state-button" internal const val SSR_HYDRATION_STATE_VALUE_ID = "ssr-hydration-state-value" 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 new file mode 100644 index 0000000000..6e2f2dd037 --- /dev/null +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToStreamTest.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web.dom + +import androidx.compose.runtime.Composable +import kotlinx.browser.dom.Element +import org.jetbrains.compose.web.composeHtmlToStream +import org.jetbrains.compose.web.composeHtmlToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ComposeHtmlToStreamTest { + @Test + fun defaultChunkSizeEmitsAt2048CodeUnitsDuringComposition() { + val text = "x".repeat(2048) + for (key in listOf(null, "stream-default-chunk-size")) { + val chunks = mutableListOf() + composeHtmlToStream(sink = chunks::add, hydratable = false, key = key) { + Text(text) + assertEquals(listOf(text), chunks) + Text("tail") + } + assertEquals(listOf(text, "tail"), chunks) + } + } + + @Test + fun emitsTextDuringCompositionWithoutSplittingTextCalls() { + val chunks = mutableListOf() + var streaming = true + val content: @Composable () -> Unit = { + Text("ab&") + if (streaming) assertEquals(listOf("ab&"), chunks) + Div { + Text("first") + Text("second") + Span { Text("๐Ÿ˜€ <&>") } + } + Text("tail") + } + + composeHtmlToStream(sink = chunks::add, chunkSize = 4, content = content) + streaming = false + + assertEquals(composeHtmlToString(content = content), chunks.joinToString("")) + assertTrue(chunks.size > 1) + assertTrue(chunks.all { it.isNotEmpty() }) + assertTrue(chunks.any { it.length > 4 }) + } + + @Test + fun streamsLeadingNewlineElementsAndBuffersElementsRequiringValidation() { + val chunks = mutableListOf() + var streaming = true + val content: @Composable () -> Unit = { + Text("prefix") + TagElement("pre", null) { + Text("\nline") + if (streaming) assertTrue(chunks.joinToString("").endsWith("
\n\nline"))
+                Span { Text("child") }
+            }
+            TagElement("textarea", null) { Text("\nA & B") }
+            TagElement("listing", null) { Text("\nline") }
+            TagElement("noscript", null) { Span { Text("fallback") } }
+            TagElement("script", null) { Text("const x = '<&';") }
+        }
+
+        composeHtmlToStream(sink = chunks::add, chunkSize = 1, content = content)
+        streaming = false
+
+        assertEquals(composeHtmlToString(content = content), chunks.joinToString(""))
+        assertTrue(chunks.all { it.isNotEmpty() })
+    }
+
+    @Test
+    fun repairsOnlyTheFirstSerializedNewlineAcrossEmptyTextAndNestedElements() {
+        for (tag in listOf("pre", "textarea", "listing")) {
+            val chunks = mutableListOf()
+            composeHtmlToStream(sink = chunks::add, chunkSize = 1) {
+                TagElement(tag, null) {
+                    Text("")
+                    Text("\n")
+                    Text("tail")
+                }
+            }
+            val boundary = if (tag == "textarea") "" else ""
+            assertEquals("<$tag>\n\n${boundary}tail", chunks.joinToString(""))
+            assertTrue(chunks.size > 1)
+        }
+
+        val chunks = mutableListOf()
+        composeHtmlToStream(sink = chunks::add, chunkSize = 1) {
+            TagElement("pre", null) {
+                TagElement("pre", null) { Text("\ninner") }
+                Text("\nouter")
+            }
+            TagElement("pre", null) { Span { Text("\nchild") } }
+            TagElement("pre", null) { Text("\r\nline") }
+            TagElement("textarea", null) { Text("") }
+        }
+        assertEquals(
+            "
\n\ninner
\nouter
" + + "
\nchild

\nline
", + chunks.joinToString(""), + ) + } + + @Test + fun largeLeadingNewlineElementsEmitBeforeTheirChildrenFinish() { + for (tag in listOf("pre", "textarea", "listing")) { + val chunks = mutableListOf() + composeHtmlToStream(sink = chunks::add, chunkSize = 32) { + TagElement(tag, null) { + Text("\n" + "๐Ÿ˜€&".repeat(100)) + assertTrue(chunks.isNotEmpty()) + assertTrue(chunks.joinToString("").startsWith("<$tag>\n\n")) + Text("last") + } + } + assertTrue(chunks.joinToString("").endsWith("last")) + } + } + + @Test + fun failedBufferedElementsDoNotEmitInvalidMarkupAndKeyCanBeReused() { + for (invalid in listOf<@Composable () -> Unit>( + { TagElement("script", null) { Text("") } }, + { + TagElement("noscript", null) { + TagElement("script", null) { Text("") } + } + }, + )) { + val chunks = mutableListOf() + assertFailsWith { + composeHtmlToStream(sink = chunks::add, chunkSize = 1, key = "stream-invalid") { + Text("valid") + invalid() + } + } + assertEquals(listOf("valid"), chunks) + assertEquals("ok", composeHtmlToString(key = "stream-invalid") { Text("ok") }) + } + } + + @Test + fun stringAndStreamRendersShareKeyedCompositionAndClearTheBuffer() { + val chunks = mutableListOf() + composeHtmlToStream(sink = chunks::add, chunkSize = 3, key = "stream-shared") { + Div { Text("long & text".repeat(100)) } + } + assertEquals( + composeHtmlToString { Div { Text("long & text".repeat(100)) } }, + chunks.joinToString(""), + ) + + assertEquals( + "
short
", + composeHtmlToString(key = "stream-shared") { Div { Text("short") } }, + ) + chunks.clear() + composeHtmlToStream(sink = chunks::add, chunkSize = 3, key = "stream-shared") { Text("last") } + assertEquals(listOf("last"), chunks) + + assertFailsWith { + composeHtmlToStream(sink = chunks::add, chunkSize = 0) { Text("unused") } + } + } + + @Test + fun sinkFailureDisposesTheKeyedRenderer() { + assertFailsWith { + composeHtmlToStream( + sink = { throw IllegalStateException("sink failed") }, + chunkSize = 1, + key = "stream-sink-failure", + ) { + Text("first") + } + } + assertEquals("second", composeHtmlToString(key = "stream-sink-failure") { Text("second") }) + } +} 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 new file mode 100644 index 0000000000..0088d60a64 --- /dev/null +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/ComposeHtmlToStream.kt @@ -0,0 +1,57 @@ +/* + * 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:JvmName("ComposeHtmlToWriterKt") + +package org.jetbrains.compose.web + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import java.io.Writer + +/** + * Composes [content] once and writes HTML synchronously to [writer], without creating a String + * for each chunk. The writer is not flushed or closed. The writer must consume each write before returning. + * + * [chunkSize] is a target minimum in UTF-16 code units. Tags and individual `Text` calls stay + * intact. Elements requiring content validation are emitted only after validation succeeds. + * 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. + */ +fun composeHtmlToStream( + writer: Writer, + chunkSize: Int = 2048, + hydratable: Boolean = true, + key: String? = null, + 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() + } + } + if (key == null) { + composeHtmlString(hydratable = hydratable, chunkSize = chunkSize, chunkSink = chunkSink, content = validatedContent) + } else { + composeReusableHtmlString(key, hydratable, chunkSize = chunkSize, chunkSink = chunkSink, content = validatedContent) + } +} + +/** Creates a Writer sink that reuses character storage across chunks within one render. */ +internal fun htmlWriterSink(writer: Writer): (StringBuilder) -> Unit { + // Grow on demand so an unusually large target does not allocate before any HTML is written. + var characters = CharArray(0) + return { buffer -> + if (characters.size < buffer.length) characters = CharArray(buffer.length) + buffer.getChars(0, buffer.length, characters, 0) + writer.write(characters, 0, buffer.length) + } +} 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 new file mode 100644 index 0000000000..a0fe792a5c --- /dev/null +++ b/html/eap/core/src/jvmMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt @@ -0,0 +1,35 @@ +/* + * 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:JvmName("HydratedDocumentToWriterKt") + +package org.jetbrains.compose.web + +import androidx.compose.runtime.Composable +import java.io.Writer + +/** + * Composes one complete HTML document and writes it synchronously to [writer], without creating + * a String for each chunk. Output and document validation follow [renderHydratedDocumentToStream] + * with a String sink, including the doctype, hydration state, and validation mode. + * + * [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. + */ +fun renderHydratedDocumentToStream( + writer: Writer, + chunkSize: Int = 2048, + validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, + content: @Composable () -> Unit, +) { + require(chunkSize > 0) { "chunkSize must be positive" } + composeHydratedDocument( + validateStrictly, + chunkSize = chunkSize, + chunkSink = htmlWriterSink(writer), + content = content, + ) +} 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 new file mode 100644 index 0000000000..0e31fee808 --- /dev/null +++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/HydratedDocumentToWriterTest.kt @@ -0,0 +1,128 @@ +/* + * 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 java.io.IOException +import java.io.Writer +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.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class HydratedDocumentToWriterTest { + private class RecordingWriter : Writer() { + val chunks = mutableListOf() + var flushes = 0 + var closes = 0 + override fun write(chars: CharArray, offset: Int, length: Int) { + assertTrue(length > 0) + chunks.add(String(chars, offset, length)) + } + override fun write(text: String, offset: Int, length: Int) = error("must write a character array") + override fun flush() { flushes++ } + override fun close() { closes++ } + } + + @Test + fun defaultChunkSizeWritesAt2048CodeUnitsDuringComposition() { + val text = "x".repeat(2048) + val writer = RecordingWriter() + renderHydratedDocumentToStream(writer) { + Html { + Body { + Text(text) + assertEquals(listOf("$text"), writer.chunks) + } + } + } + assertEquals(listOf("$text", ""), writer.chunks) + } + + @Test + fun defaultValidationModeIsTransportedToTheWriter() { + val writer = RecordingWriter() + renderHydratedDocumentToStream(writer) { + Html { Body { HydrationRoot(Unit, { "null" }) {} } } + } + assertEquals( + defaultHtmlValidationMode() == HtmlValidationMode.Strict, + HydrationValidationAttribute in writer.chunks.single(), + ) + } + + @Test + fun writerMatchesStringSinkChunksAndDoesNotFlushOrClose() { + val content: @Composable () -> Unit = { + Html { + Body { + HydrationRoot("๐Ÿ˜€\r\u0000", { it }, hydrationId = "application") { + Text("<&๐Ÿ˜€") + Text("adjacent") + } + HydrationRoot(42, { it.toString() }, hydrationId = "counter") { Text(it.toString()) } + } + } + } + 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) + assertEquals(strings, writer.chunks) + assertEquals(renderHydratedDocument(strict, content), writer.chunks.joinToString("")) + assertEquals(0, writer.flushes) + assertEquals(0, writer.closes) + } + } + } + + @Test + fun rejectsInvalidSizesBeforeWritingOrComposingAndValidatesDocuments() { + val writer = RecordingWriter() + var composed = false + for (size in listOf(0, -1)) { + assertFailsWith { + renderHydratedDocumentToStream(writer, size) { composed = true; Html() } + } + } + assertFalse(composed) + assertTrue(writer.chunks.isEmpty()) + assertFailsWith { + renderHydratedDocumentToStream(writer, chunkSize = 1) { Div() } + } + assertEquals("
", writer.chunks.joinToString("")) + assertEquals(0, writer.flushes) + assertEquals(0, writer.closes) + } + + @Test + fun propagatesWriterFailuresDuringCompositionAndCanRenderAgain() { + val failure = IOException("write failed") + val writer = object : Writer() { + override fun write(chars: CharArray, offset: Int, length: Int) { throw failure } + override fun flush() = error("must not flush") + override fun close() = error("must not close") + } + var childrenComposed = false + assertSame(failure, assertFailsWith { + renderHydratedDocumentToStream(writer, chunkSize = 1) { + Html { childrenComposed = true } + } + }) + assertFalse(childrenComposed) + val nextWriter = RecordingWriter() + renderHydratedDocumentToStream(nextWriter) { Html { Body { Text("next") } } } + assertEquals("next", nextWriter.chunks.single()) + } +} diff --git a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/SsrHydrationFixtureGenerator.jvm.kt b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/SsrHydrationFixtureGenerator.jvm.kt index 8ad7259f81..ccfde8bb91 100644 --- a/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/SsrHydrationFixtureGenerator.jvm.kt +++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/SsrHydrationFixtureGenerator.jvm.kt @@ -25,18 +25,20 @@ internal object SsrHydrationFixtureGenerator { } outputDirectory.writeHydrationStateFixture("ssr-hydration-state.html") outputDirectory.writeHydrationIslandsFixture("ssr-hydration-islands.html") + outputDirectory.writeHydrationStateFixture("ssr-hydration-state-stream-fast.html", streamedStrictMode = false) + outputDirectory.writeHydrationStateFixture("ssr-hydration-state-stream-strict.html", streamedStrictMode = true) } private fun File.writeFixture(name: String, content: @Composable () -> Unit) { resolve(name).writeText("\n ${composeHtmlToString(content = content)}\n") } - private fun File.writeHydrationStateFixture(name: String) { + private fun File.writeHydrationStateFixture(name: String, streamedStrictMode: Boolean? = null) { val state = SsrHydrationState( label = "Loaded by JVM ", count = 41, ) - val rendered = renderHydratedDocument { + val content: @Composable () -> Unit = { Html { Body { HydrationRoot( @@ -50,7 +52,13 @@ internal object SsrHydrationFixtureGenerator { } } - resolve(name).writeText(rendered) + if (streamedStrictMode == null) { + resolve(name).writeText(renderHydratedDocument(content = content)) + } else { + resolve(name).bufferedWriter().use { writer -> + renderHydratedDocumentToStream(writer, chunkSize = 1, validateStrictly = streamedStrictMode, content = content) + } + } } private fun File.writeHydrationIslandsFixture(name: String) { 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 new file mode 100644 index 0000000000..6d9f82668e --- /dev/null +++ b/html/eap/core/src/jvmTest/kotlin/org/jetbrains/compose/web/dom/ComposeHtmlToWriterTest.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.web.dom + +import androidx.compose.runtime.Composable +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.OutputStreamWriter +import java.io.Writer +import kotlinx.browser.dom.Element +import org.jetbrains.compose.web.composeHtmlToStream +import org.jetbrains.compose.web.composeHtmlToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ComposeHtmlToWriterTest { + private class RecordingWriter : Writer() { + val chunks = mutableListOf() + var flushes = 0 + var closes = 0 + override fun write(chars: CharArray, offset: Int, length: Int) { + assertTrue(length > 0) + chunks.add(String(chars, offset, length)) + } + override fun flush() { flushes++ } + override fun close() { closes++ } + } + + @Test + fun defaultChunkSizeWritesAt2048CodeUnitsDuringComposition() { + val text = "x".repeat(2048) + for (key in listOf(null, "writer-default-chunk-size")) { + val writer = RecordingWriter() + composeHtmlToStream(writer, hydratable = false, key = key) { + Text(text) + assertEquals(listOf(text), writer.chunks) + Text("tail") + } + assertEquals(listOf(text, "tail"), writer.chunks) + } + } + + @Test + fun writerAndStringSinkHaveEquivalentOutputAndChunkBoundaries() { + val content: @Composable () -> Unit = { + Div(attrs = { attr("title", "<&\"\n\t\r"); classes("one", "two") }) { + Text("๐Ÿ˜€<&>ฮฑ") + Text("adjacent") + TagElement("textarea", null) { Text("\nA & B") } + TagElement("pre", null) { Span { Text("child") }; Text("\nline") } + TagElement("script", null) { Text("const a = '<&';") } + TagElement(StringElementBuilder("svg", "http://www.w3.org/2000/svg"), null) { + TagElement(StringElementBuilder("path", "http://www.w3.org/2000/svg"), { attr("d", "M 0 0") }, null) + } + } + } + for (size in listOf(1, 4, 32, 2048, 4096, Int.MAX_VALUE)) { + for (hydratable in listOf(false, true)) { + for (strict in listOf(false, true)) { + val strings = mutableListOf() + val writer = RecordingWriter() + composeHtmlToStream(strings::add, size, hydratable, validateStrictly = strict, content = content) + composeHtmlToStream(writer, size, hydratable, validateStrictly = strict, content = content) + assertEquals(strings, writer.chunks) + assertEquals(composeHtmlToString(hydratable, validateStrictly = strict, content = content), writer.chunks.joinToString("")) + assertEquals(0, writer.flushes) + assertEquals(0, writer.closes) + } + } + } + } + + @Test + fun emptyContentDoesNotWriteFlushOrCloseAndRejectsInvalidSizes() { + val writer = RecordingWriter() + composeHtmlToStream(writer) {} + assertTrue(writer.chunks.isEmpty()) + assertEquals(0, writer.flushes) + assertEquals(0, writer.closes) + for (size in listOf(0, -1)) assertFailsWith { + composeHtmlToStream(writer, chunkSize = size) {} + } + } + + @Test + fun utf8WriterPreservesSupplementaryCharactersAcrossWrites() { + val bytes = ByteArrayOutputStream() + val writer = OutputStreamWriter(bytes, Charsets.UTF_8) + composeHtmlToStream(writer, chunkSize = 1, hydratable = false) { + Text("\uD83D") + Text("\uDE00") + Text("ฮฑ&") + } + writer.flush() + assertEquals("๐Ÿ˜€ฮฑ&", bytes.toString(Charsets.UTF_8)) + } + + @Test + fun writesSynchronouslyBeforeCompositionContinuesAndSupportsMixedKeyedCalls() { + val writer = RecordingWriter() + composeHtmlToStream(writer, chunkSize = 1, key = "writer-shared") { + Text("first") + assertEquals(listOf("first"), writer.chunks) + Text("second") + } + assertEquals("short", composeHtmlToString(key = "writer-shared") { Text("short") }) + val strings = mutableListOf() + composeHtmlToStream(sink = strings::add, chunkSize = 1, key = "writer-shared") { Text("last") } + assertEquals(listOf("last"), strings) + } + + @Test + fun writerFailureAndContentValidationFailureDisposeTheKey() { + val failing = object : Writer() { + override fun write(chars: CharArray, offset: Int, length: Int) { throw IOException("write failed") } + override fun flush() = Unit + override fun close() = Unit + } + assertFailsWith { + composeHtmlToStream(failing, chunkSize = 1, key = "writer-failure") { Text("first") } + } + assertEquals("second", composeHtmlToString(key = "writer-failure") { Text("second") }) + for (invalid in listOf<@Composable () -> Unit>( + { TagElement("script", null) { Text("") } }, + { TagElement("script", { attr("SRC", "script.js") }) { Text("") } }, + { TagElement("noscript", null) { TagElement("script", null) { Text("") } } }, + { Div({ attr("title", "\u0000") }) }, + { TagElement(StringElementBuilder("svg", "http://www.w3.org/2000/svg"), { attr("viewBox", "a"); attr("VIEWBOX", "b") }, null) }, + )) { + val writer = RecordingWriter() + assertFailsWith { + composeHtmlToStream(writer, chunkSize = 1, key = "writer-invalid", validateStrictly = true) { + Text("valid") + invalid() + } + } + assertEquals(listOf("valid"), writer.chunks) + assertEquals("ok", composeHtmlToString(key = "writer-invalid") { Text("ok") }) + } + } + + @Test + fun shrinksOldLargeBuffersAfterSmallRendersButReusesRepeatedLargeBuffers() { + val key = "writer-buffer-capacity" + val large = "ฮฑ".repeat(1024 * 1024) + composeHtmlToString(key = key) { Text(large) } + val output = keyedOutput(key) + val largeCapacity = output.capacity() + assertTrue(largeCapacity >= large.length) + composeHtmlToString(key = key) { Text(large) } + assertEquals(largeCapacity, output.capacity()) + assertEquals("small", composeHtmlToString(key = key) { Text("small") }) + assertTrue(output.capacity() <= 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 + } +} diff --git a/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/JvmSsrHydrationStateTest.kt b/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/JvmSsrHydrationStateTest.kt index 03cd1f64cc..7c32ebcfa4 100644 --- a/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/JvmSsrHydrationStateTest.kt +++ b/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/JvmSsrHydrationStateTest.kt @@ -18,7 +18,23 @@ class JvmSsrHydrationStateTest { // Fetches a JVM-rendered document, hydrates it, and verifies node adoption and client updates. @Test fun jvmStateHydratesWithoutClientAccessToItsSource() = MainScope().promise { - val fixtureHtml = fetchTestResourceText(SSR_HYDRATION_STATE_FIXTURE_URL) + hydrateFixture(SSR_HYDRATION_STATE_FIXTURE_URL) + } + + @Test + fun jvmStreamedStateHydratesInFastMode() = MainScope().promise { + hydrateFixture(SSR_HYDRATION_STATE_STREAM_FAST_FIXTURE_URL, strict = false) + } + + @Test + fun jvmStreamedStateHydratesInStrictMode() = MainScope().promise { + hydrateFixture(SSR_HYDRATION_STATE_STREAM_STRICT_FIXTURE_URL, strict = true) + } + + private suspend fun hydrateFixture(url: String, strict: Boolean? = null) { + val fixtureHtml = fetchTestResourceText(url) + assertEquals("", fixtureHtml.take(15)) + if (strict != null) assertEquals(strict, HydrationValidationAttribute in fixtureHtml) assertContains(fixtureHtml, "Loaded by JVM <backend>: 41") assertContains(fixtureHtml, "\"label\":\"Loaded by JVM <backend>\"") diff --git a/html/test-utils/conf/configure-browser-tests.js b/html/test-utils/conf/configure-browser-tests.js index 3a7d9cde37..ca05eae913 100644 --- a/html/test-utils/conf/configure-browser-tests.js +++ b/html/test-utils/conf/configure-browser-tests.js @@ -29,6 +29,8 @@ module.exports = function configureBrowserTests(config) { "ssr-hydration.html", "ssr-hydration-state.html", "ssr-hydration-islands.html", + "ssr-hydration-state-stream-fast.html", + "ssr-hydration-state-stream-strict.html", "ssr-number-hydration.html", "svg-ssr-hydration.html", ]) {