("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$tag>", 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$tag>"))
+ }
+ }
+
+ @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",
]) {