")
}
val root = rootElement.unsafeCast
()
- val state = findUniqueProtocolElement(
- within = within,
- selector = "[$HydrationStateAttribute]",
- description = "hydration state element",
- )
+ val state = requireUniqueProtocolElement(matchingState, stateCount, "hydration state element")
if (root.contains(state)) {
invalidHydrationState(
"The Compose hydration state element must be outside the hydration root"
@@ -254,24 +275,23 @@ private fun findHydrationProtocol(within: ParentNode): HydrationBootstrapData {
)
}
-private fun findUniqueProtocolElement(
- within: ParentNode,
- selector: String,
+private fun requireUniqueProtocolElement(
+ element: Element?,
+ count: Int,
description: String,
): Element {
- val matches = within.querySelectorAll(selector)
- if (matches.length != 1) {
- val timingHint = if (matches.length == 0) {
- " Make sure hydrateRoot runs after the hydration root and state have been parsed; " +
- "defer its bootstrap script or move it to the end of ."
+ if (count != 1) {
+ val timingHint = if (count == 0) {
+ " Make sure hydrateRoot runs after the hydration root and state have been parsed. " +
+ "Defer its bootstrap script or move it to the end of ."
} else {
""
}
invalidHydrationState(
- "Expected exactly one Compose $description, but found ${matches.length}." + timingHint,
+ "Expected exactly one Compose $description, but found $count." + timingHint,
)
}
- return matches.item(0) as? Element
+ return element
?: invalidHydrationState("The Compose $description is not an element")
}
diff --git a/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/HydrationStateTest.kt b/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/HydrationStateTest.kt
index 34bb2d712b..7d48071dda 100644
--- a/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/HydrationStateTest.kt
+++ b/html/eap/core/src/webTest/kotlin/org/jetbrains/compose/web/HydrationStateTest.kt
@@ -76,6 +76,138 @@ class HydrationStateTest {
}
}
+ @Test
+ fun namedIslandsHydrateInDocumentOrderAndAdoptTheirOwnNodes() {
+ val fixture = installNamedDocument()
+ val header = fixture.header
+ val footer = fixture.footer
+ val cartNode = fixture.cartRoot.firstChild
+ val accountNode = fixture.accountRoot.firstChild
+ val cartState = fixture.cartState
+ val accountState = fixture.accountState
+ val decoded = mutableListOf()
+
+ val accountComposition = hydrateRoot(
+ deserializeState = { it.also { value -> decoded += "account:$value" } },
+ within = fixture.host,
+ hydrationId = "account",
+ onHydrationMismatch = { throw it },
+ ) { state -> Span { Text("account: $state") } }
+
+ try {
+ assertEquals(listOf("account:account state"), decoded)
+ assertSame(accountNode, fixture.accountRoot.firstChild)
+ assertSame(cartNode, fixture.cartRoot.firstChild)
+
+ val cartComposition = hydrateRoot(
+ deserializeState = { it.also { value -> decoded += "cart:$value" } },
+ within = fixture.host,
+ hydrationId = "cart",
+ onHydrationMismatch = { throw it },
+ ) { state -> Span { Text("cart: $state") } }
+
+ try {
+ assertEquals(listOf("account:account state", "cart:cart state"), decoded)
+ assertSame(cartNode, fixture.cartRoot.firstChild)
+ assertSame(accountNode, fixture.accountRoot.firstChild)
+ assertEquals("cart: cart state", fixture.cartRoot.textContent)
+ assertEquals("account: account state", fixture.accountRoot.textContent)
+ assertSame(header, fixture.host.firstChild)
+ assertSame(footer, fixture.host.lastChild)
+ val states = fixture.host.querySelectorAll("[$HydrationStateAttribute]")
+ assertSame(cartState, states.item(0))
+ assertSame(accountState, states.item(1))
+ } finally {
+ cartComposition.dispose()
+ }
+ } finally {
+ accountComposition.dispose()
+ }
+ }
+
+ @Test
+ fun missingMismatchedOrDuplicateNamedPairFailsBeforeDecodingOrDomChanges() {
+ val mutations: List Unit>> = listOf(
+ "missing root" to { it.cartRoot.parentNode?.removeChild(it.cartRoot) },
+ "missing state" to { it.cartState.parentNode?.removeChild(it.cartState) },
+ "mismatched state ID" to {
+ it.cartState.setAttribute(HydrationForAttribute, "other")
+ },
+ "duplicate root" to {
+ it.host.appendChild(it.cartRoot.cloneNode(deep = true))
+ },
+ "duplicate state" to {
+ it.host.appendChild(it.cartState.cloneNode(deep = true))
+ },
+ )
+
+ mutations.forEach { (case, mutate) ->
+ val fixture = installNamedDocument()
+ val cartNode = fixture.cartRoot.firstChild
+ val accountNode = fixture.accountRoot.firstChild
+ mutate(fixture)
+ var decoded = false
+ var mismatchCalled = false
+
+ assertFailsWith(case) {
+ hydrateRoot(
+ deserializeState = { decoded = true; it },
+ within = fixture.host,
+ hydrationId = "cart",
+ onHydrationMismatch = { mismatchCalled = true },
+ ) { Span { Text("client") } }
+ }
+
+ assertFalse(decoded, case)
+ assertFalse(mismatchCalled, case)
+ assertSame(cartNode, fixture.cartRoot.firstChild, case)
+ assertSame(accountNode, fixture.accountRoot.firstChild, case)
+ }
+ }
+
+ @Test
+ // Special characters in IDs are treated as ordinary text when finding its root and state.
+ fun namedHydrationIdIsComparedAsAnAttributeValue() {
+ val hydrationId = "cart:items[0]"
+ val fixture = installNamedDocument(cartId = hydrationId)
+ val serverNode = fixture.cartRoot.firstChild
+ var decoded: String? = null
+
+ val composition = hydrateRoot(
+ deserializeState = { it.also { value -> decoded = value } },
+ within = fixture.host,
+ hydrationId = hydrationId,
+ onHydrationMismatch = { throw it },
+ ) { state -> Span { Text("cart: $state") } }
+
+ try {
+ assertEquals("cart state", decoded)
+ assertSame(serverNode, fixture.cartRoot.firstChild)
+ } finally {
+ composition.dispose()
+ }
+ }
+
+ @Test
+ fun blankClientHydrationIdFailsBeforeDecodingOrDomChanges() {
+ val fixture = installNamedDocument()
+ val serverNode = fixture.cartRoot.firstChild
+ var decoded = false
+
+ listOf("", " \t ").forEach { hydrationId ->
+ val failure = assertFailsWith {
+ hydrateRoot(
+ deserializeState = { decoded = true; it },
+ within = fixture.host,
+ hydrationId = hydrationId,
+ ) { Span { Text("client") } }
+ }
+ assertContains(failure.message.orEmpty(), "hydrationId must not be blank")
+ assertFalse(decoded)
+ assertSame(serverNode, fixture.cartRoot.firstChild)
+ }
+ }
+
// Reads scopeElement in a client effect to verify that the receiver is the discovered root.
@Test
fun clientContentReceivesTheHydrationRootElementScope() {
@@ -197,7 +329,7 @@ class HydrationStateTest {
within = missing.host,
) {}
}
- assertContains(failure.message.orEmpty(), "defer its bootstrap script")
+ assertContains(failure.message.orEmpty(), "bootstrap script")
assertFalse(missingDecoded)
val duplicate = installDocument("state", { it }) {}
@@ -360,12 +492,52 @@ class HydrationStateTest {
)
}
+ private fun installNamedDocument(cartId: String = "cart"): NamedFixture {
+ val rendered = renderHydratedDocument(validateStrictly = true) {
+ Html {
+ Body {
+ Span(attrs = { attr("id", "island-header") }) { Text("Header") }
+ HydrationRoot("cart state", { it }, hydrationId = cartId) { state ->
+ Span { Text("cart: $state") }
+ }
+ HydrationRoot("account state", { it }, hydrationId = "account") { state ->
+ Span { Text("account: $state") }
+ }
+ Span(attrs = { attr("id", "island-footer") }) { Text("Footer") }
+ }
+ }
+ }
+ val parsed = DOMParser().parseFromString(rendered, "text/html".toJsString())
+ val host = assertNotNull(parsed.body)
+ val roots = host.querySelectorAll("[$HydrationRootAttribute]")
+ val states = host.querySelectorAll("[$HydrationStateAttribute]")
+ return NamedFixture(
+ host = host,
+ header = assertNotNull(host.querySelector("#island-header")),
+ footer = assertNotNull(host.querySelector("#island-footer")),
+ cartRoot = assertNotNull(roots.item(0) as? HTMLDivElement),
+ cartState = assertNotNull(states.item(0) as? Element),
+ accountRoot = assertNotNull(roots.item(1) as? HTMLDivElement),
+ accountState = assertNotNull(states.item(1) as? Element),
+ )
+ }
+
private data class Fixture(
val host: HTMLElement,
val root: HTMLDivElement,
val state: Element,
)
+ private data class NamedFixture(
+ val host: HTMLElement,
+ val header: Element,
+ val footer: Element,
+ val cartRoot: HTMLDivElement,
+ val cartState: Element,
+ val accountRoot: HTMLDivElement,
+ val accountState: Element,
+ )
+
private data class TestState(
val label: String,
val count: Int,
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 eb88344284..03cd1f64cc 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
@@ -62,6 +62,76 @@ class JvmSsrHydrationStateTest {
}
}
+ @Test
+ fun jvmIslandsHydrateIndependentlyInReverseOrder() = MainScope().promise {
+ val fixtureHtml = fetchTestResourceText(SSR_HYDRATION_ISLANDS_FIXTURE_URL)
+ val parsed = DOMParser().parseFromString(fixtureHtml, "text/html".toJsString())
+
+ val header = assertNotNull(parsed.getElementById(SSR_HYDRATION_ISLANDS_HEADER_ID))
+ val footer = assertNotNull(parsed.getElementById(SSR_HYDRATION_ISLANDS_FOOTER_ID))
+ val cartRoot = assertNotNull(parsed.querySelector("[$HydrationRootAttribute=\"$SSR_HYDRATION_CART_ID\"]"))
+ val accountRoot = assertNotNull(parsed.querySelector("[$HydrationRootAttribute=\"$SSR_HYDRATION_ACCOUNT_ID\"]"))
+ val cartState = assertNotNull(parsed.querySelector("[$HydrationForAttribute=\"$SSR_HYDRATION_CART_ID\"]"))
+ val accountState = assertNotNull(parsed.querySelector("[$HydrationForAttribute=\"$SSR_HYDRATION_ACCOUNT_ID\"]"))
+ val cartButton = assertNotNull(
+ parsed.getElementById(ssrHydrationIslandButtonId(SSR_HYDRATION_CART_ID)) as? HTMLElement,
+ )
+ val accountButton = assertNotNull(
+ parsed.getElementById(ssrHydrationIslandButtonId(SSR_HYDRATION_ACCOUNT_ID)) as? HTMLElement,
+ )
+ val cartValue = assertNotNull(parsed.getElementById(ssrHydrationIslandValueId(SSR_HYDRATION_CART_ID)))
+ val accountValue = assertNotNull(parsed.getElementById(ssrHydrationIslandValueId(SSR_HYDRATION_ACCOUNT_ID)))
+
+ val accountComposition = hydrateRoot(
+ deserializeState = ::decodeSsrHydrationState,
+ within = parsed,
+ hydrationId = SSR_HYDRATION_ACCOUNT_ID,
+ onHydrationMismatch = { throw it },
+ ) { initialState ->
+ SsrHydrationIsland(SSR_HYDRATION_ACCOUNT_ID, initialState)
+ }
+
+ try {
+ val cartComposition = hydrateRoot(
+ deserializeState = ::decodeSsrHydrationState,
+ within = parsed,
+ hydrationId = SSR_HYDRATION_CART_ID,
+ onHydrationMismatch = { throw it },
+ ) { initialState ->
+ SsrHydrationIsland(SSR_HYDRATION_CART_ID, initialState)
+ }
+
+ try {
+ assertSame(cartRoot, parsed.querySelector("[$HydrationRootAttribute=\"$SSR_HYDRATION_CART_ID\"]"))
+ assertSame(accountRoot, parsed.querySelector("[$HydrationRootAttribute=\"$SSR_HYDRATION_ACCOUNT_ID\"]"))
+ assertSame(cartState, parsed.querySelector("[$HydrationForAttribute=\"$SSR_HYDRATION_CART_ID\"]"))
+ assertSame(accountState, parsed.querySelector("[$HydrationForAttribute=\"$SSR_HYDRATION_ACCOUNT_ID\"]"))
+ assertSame(cartButton, parsed.getElementById(ssrHydrationIslandButtonId(SSR_HYDRATION_CART_ID)))
+ assertSame(accountButton, parsed.getElementById(ssrHydrationIslandButtonId(SSR_HYDRATION_ACCOUNT_ID)))
+ assertSame(cartValue, parsed.getElementById(ssrHydrationIslandValueId(SSR_HYDRATION_CART_ID)))
+ assertSame(accountValue, parsed.getElementById(ssrHydrationIslandValueId(SSR_HYDRATION_ACCOUNT_ID)))
+ assertEquals("Cart loaded by JVM: 41", cartValue.textContent)
+ assertEquals("Account loaded by JVM: 7", accountValue.textContent)
+
+ cartButton.click()
+ delay(100.milliseconds)
+ assertEquals("Cart loaded by JVM: 42", cartValue.textContent)
+ assertEquals("Account loaded by JVM: 7", accountValue.textContent)
+
+ accountButton.click()
+ delay(100.milliseconds)
+ assertEquals("Cart loaded by JVM: 42", cartValue.textContent)
+ assertEquals("Account loaded by JVM: 8", accountValue.textContent)
+ assertSame(header, parsed.getElementById(SSR_HYDRATION_ISLANDS_HEADER_ID))
+ assertSame(footer, parsed.getElementById(SSR_HYDRATION_ISLANDS_FOOTER_ID))
+ } finally {
+ cartComposition.dispose()
+ }
+ } finally {
+ accountComposition.dispose()
+ }
+ }
+
private fun decodeSsrHydrationState(json: String): SsrHydrationState {
val decoded = decodeHydrationTestState(json)
return SsrHydrationState(
diff --git a/html/eap/examples/hydration-islands/build.gradle.kts b/html/eap/examples/hydration-islands/build.gradle.kts
new file mode 100644
index 0000000000..77159f0471
--- /dev/null
+++ b/html/eap/examples/hydration-islands/build.gradle.kts
@@ -0,0 +1,67 @@
+@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
+
+import org.gradle.api.tasks.JavaExec
+
+val renderedSite = layout.buildDirectory.dir("generated/site")
+val renderedIndex = renderedSite.map { it.file("index.html") }
+val kotlinxBrowserCommonSubsetVersion: String =
+ providers.gradleProperty("compose.html.eap.kotlinx-browser-common-subset.version").get()
+val composeVersion: String = providers.gradleProperty("compose.version").get()
+
+plugins {
+ kotlin("multiplatform")
+ id("org.jetbrains.compose")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+kotlin {
+ jvm()
+ js(IR) {
+ browser {
+ commonWebpackConfig { outputFileName = "app.js" }
+ }
+ binaries.executable()
+ }
+ wasmJs {
+ browser {
+ commonWebpackConfig { outputFileName = "app.js" }
+ }
+ binaries.executable()
+ }
+
+ applyDefaultHierarchyTemplate()
+
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
+ implementation(project(":html-core-eap"))
+ implementation(
+ "org.jetbrains.compose.html:kotlinx-browser-common-subset:" +
+ kotlinxBrowserCommonSubsetVersion
+ )
+ }
+ }
+ val webMain by getting {
+ resources.srcDir(renderedSite)
+ }
+ }
+}
+
+val jvmMain = kotlin.targets.getByName("jvm").compilations.getByName("main")
+val renderHydrationIslandsExample = tasks.register("renderHydrationIslandsExample") {
+ group = "application"
+ description = "Renders the type playground and its three hydration islands."
+ dependsOn(jvmMain.compileTaskProvider)
+ mainClass.set("ServerKt")
+ classpath(jvmMain.output.allOutputs)
+ classpath(jvmMain.runtimeDependencyFiles)
+ args(renderedIndex.get().asFile.absolutePath)
+ outputs.file(renderedIndex)
+}
+
+listOf("js", "wasmJs").forEach { targetName ->
+ tasks.named("${targetName}ProcessResources") {
+ dependsOn(renderHydrationIslandsExample)
+ }
+}
diff --git a/html/eap/examples/hydration-islands/src/commonMain/kotlin/App.kt b/html/eap/examples/hydration-islands/src/commonMain/kotlin/App.kt
new file mode 100644
index 0000000000..c8ddc053e8
--- /dev/null
+++ b/html/eap/examples/hydration-islands/src/commonMain/kotlin/App.kt
@@ -0,0 +1,92 @@
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import org.jetbrains.compose.web.attributes.ButtonType
+import org.jetbrains.compose.web.attributes.type
+import org.jetbrains.compose.web.dom.*
+import org.jetbrains.compose.web.css.*
+
+@Composable
+fun WeightIsland(initialWeight: Int) {
+ var weight by remember(initialWeight) { mutableStateOf(initialWeight) }
+
+ IslandHeading("01", "Weight", "$weight")
+ Div(attrs = { classes(PageStyles.islandBody) }) {
+ Div(attrs = { classes(PageStyles.weightPreview) }) {
+ Span(attrs = { style { fontWeight(weight) } }) { Text("Ag") }
+ }
+ Div(attrs = { classes(PageStyles.controls) }) {
+ Label(forId = "weight-range") { Text("LIGHT") }
+ RangeInput(value = weight, min = 100, max = 900, step = 10) {
+ id("weight-range")
+ attr("aria-label", "Font weight")
+ onInput { event -> event.value?.toInt()?.let { weight = it } }
+ }
+ Span { Text("BOLD") }
+ }
+ }
+}
+
+@Composable
+fun SizeIsland(initialSize: Int) {
+ var size by remember(initialSize) { mutableStateOf(initialSize) }
+
+ IslandHeading("02", "Size", "$size px")
+ Div(attrs = { classes(PageStyles.islandBody) }) {
+ Div(attrs = { classes(PageStyles.sizePreview) }) {
+ Span(attrs = { style { fontSize(size.px) } }) { Text("Compose") }
+ }
+ Div(attrs = { classes(PageStyles.controls) }) {
+ Label(forId = "size-range") { Text("32 PX") }
+ RangeInput(value = size, min = 32, max = 144) {
+ id("size-range")
+ attr("aria-label", "Font size")
+ onInput { event -> event.value?.toInt()?.let { size = it } }
+ }
+ Span { Text("144 PX") }
+ }
+ }
+}
+
+@Composable
+fun TextIsland(initialText: String) {
+ var text by remember(initialText) { mutableStateOf(initialText) }
+ var editing by remember { mutableStateOf(false) }
+
+ Div(attrs = { classes(PageStyles.islandBody) }) {
+ P(attrs = { classes(PageStyles.textPreview) }) { Text(text.ifBlank { "Your next idea goes here." }) }
+ Div(attrs = { classes(PageStyles.textControls) }) {
+ Button(attrs = {
+ classes(PageStyles.editButton)
+ type(ButtonType.Button)
+ onClick { editing = !editing }
+ }) { Text(if (editing) "Done editing ↗" else "Edit text ↗") }
+ Span { Text("A LINE OF YOUR OWN, SET IN TYPE") }
+ }
+ if (editing) {
+ Label(forId = "island-text-input", attrs = { classes(PageStyles.inputLabel) }) {
+ Text("YOUR TEXT")
+ }
+ TextInput(value = text) {
+ id("island-text-input")
+ classes(PageStyles.textInput)
+ onInput { text = it.value }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun IslandHeading(number: String, title: String, value: String? = null) {
+ Div(attrs = { classes(PageStyles.islandHeading) }) {
+ Div {
+ P(attrs = { classes(PageStyles.eyebrow) }) { Text("$number / INTERACTIVE ISLAND") }
+ H2 { Text(title) }
+ }
+ if (value != null) {
+ Output(attrs = { classes(PageStyles.islandValue) }) { Text(value) }
+ }
+ }
+}
diff --git a/html/eap/examples/hydration-islands/src/commonMain/kotlin/Styles.kt b/html/eap/examples/hydration-islands/src/commonMain/kotlin/Styles.kt
new file mode 100644
index 0000000000..2407648214
--- /dev/null
+++ b/html/eap/examples/hydration-islands/src/commonMain/kotlin/Styles.kt
@@ -0,0 +1,175 @@
+import org.jetbrains.compose.web.css.*
+
+internal object PageStyles : StyleSheet(usePrefix = false) {
+ private val ink = Color("#3a281f")
+ private val terracotta = Color("#a45f4d")
+ private val rule = Color("#e6d8c9")
+ private const val serif = "Georgia, 'Iowan Old Style', 'Palatino Linotype', serif"
+
+ val page by style { maxWidth(1300.px); property("margin", "auto"); padding(0.px, 48.px, 55.px) }
+ val masthead by style {
+ display(DisplayStyle.Flex); alignItems(AlignItems.Center); justifyContent(JustifyContent.SpaceBetween)
+ gap(24.px); fontSize(10.px); fontWeight(700); letterSpacing(0.15.em)
+ padding(27.px, 0.px); property("border-bottom", "1px solid #d9c7bb")
+ }
+ val brand by style { display(DisplayStyle.Flex); alignItems(AlignItems.Center); gap(12.px) }
+ val eyebrow by style {
+ margin(0.px); color(terracotta); fontSize(11.px)
+ fontWeight(700); letterSpacing(0.17.em)
+ }
+ val hero by style { padding(95.px, 5.percent, 0.px) }
+ val lead by style {
+ margin(0.px, 0.px, 88.px); color(terracotta); property("font-size", "clamp(18px, 2.1vw, 26px)")
+ lineHeight("1.45")
+ }
+ val heroFoot by style {
+ display(DisplayStyle.Flex); alignItems(AlignItems.Center); gap(24.px)
+ fontSize(10.px); fontWeight(700); letterSpacing(0.15.em)
+ padding(22.px, 0.px); property("border-top", "1px solid #d9c7bb")
+ }
+ val paper by style {
+ overflow("hidden"); background("#fffaf1"); border(1.px, LineStyle.Solid, rule)
+ borderRadius(36.px); property("box-shadow", "0 36px 100px #805b4730")
+ }
+ val paperHeader by style {
+ display(DisplayStyle.Flex); justifyContent(JustifyContent.SpaceBetween); gap(20.px)
+ padding(28.px, 6.percent); property("border-bottom", "1px solid $rule"); fontSize(10.px)
+ fontWeight(700); letterSpacing(0.17.em)
+ }
+ val islandSection by style { padding(70.px, 6.percent); property("border-bottom", "1px solid $rule") }
+ val islandGrid by style {
+ display(DisplayStyle.Grid); gridTemplateColumns("repeat(2, minmax(0, 1fr))")
+ property("border-bottom", "1px solid $rule")
+ }
+ val codeSection by style { padding(70.px, 6.percent) }
+ val islandHeading by style {
+ display(DisplayStyle.Flex); justifyContent(JustifyContent.SpaceBetween); alignItems(AlignItems.Start)
+ gap(24.px); marginBottom(28.px)
+ }
+ val islandValue by style {
+ flex("none"); alignSelf(AlignSelf.End); color(terracotta)
+ font("400 32px/1 $serif"); letterSpacing((-0.04).em); whiteSpace("nowrap")
+ }
+ val islandBody by style {
+ padding(30.px, 36.px); background("#f5e9db"); border(1.px, LineStyle.Solid, Color("#e4cdbd"))
+ borderRadius(24.px)
+ }
+ val weightPreview by style {
+ minHeight(210.px); font("clamp(120px, 16vw, 190px)/1 system-ui, sans-serif"); letterSpacing((-0.1).em)
+ }
+ val sizePreview by style {
+ minHeight(210.px); overflowX("auto"); whiteSpace("nowrap")
+ fontFamily("Georgia", "Iowan Old Style", "Palatino Linotype", "serif"); letterSpacing((-0.075).em)
+ property("justify-content", "safe center")
+ }
+ val controls by style {
+ display(DisplayStyle.Grid); gridTemplateColumns("auto minmax(70px, 1fr) auto")
+ alignItems(AlignItems.Center); gap(12.px); paddingTop(20.px)
+ property("border-top", "1px solid #dfc9b9"); fontSize(10.px); fontWeight(700)
+ letterSpacing(0.12.em)
+ }
+ val textPreview by style {
+ display(DisplayStyle.Flex); alignItems(AlignItems.Center); justifyContent(JustifyContent.Center)
+ minHeight(210.px); margin(0.px); font("italic clamp(36px, 6vw, 76px)/1.12 $serif")
+ letterSpacing((-0.055).em); textAlign("center"); property("overflow-wrap", "anywhere")
+ }
+ val textControls by style {
+ display(DisplayStyle.Flex); justifyContent(JustifyContent.SpaceBetween); alignItems(AlignItems.Center)
+ gap(20.px); paddingTop(20.px); property("border-top", "1px solid #dfc9b9")
+ fontSize(10.px); fontWeight(700); letterSpacing(0.12.em)
+ }
+ val editButton by style {
+ padding(8.px, 0.px); border(0.px); background("transparent")
+ color(terracotta); font("inherit"); cursor("pointer")
+ textDecoration("underline"); property("text-underline-offset", "4px")
+ }
+ val inputLabel by style {
+ display(DisplayStyle.Block); margin(28.px, 0.px, 8.px); fontSize(10.px)
+ fontWeight(700); letterSpacing(0.14.em)
+ }
+ val textInput by style {
+ width(100.percent); padding(14.px, 16.px); border(1.px, LineStyle.Solid, Color("#cba891"))
+ borderRadius(9.px); background("#fffaf1"); color(ink)
+ font("18px $serif")
+ }
+ val paperFooter by style {
+ padding(30.px, 6.percent); property("border-top", "1px solid $rule"); color(Color("#8a685a"))
+ fontSize(10.px); fontWeight(700); letterSpacing(0.15.em)
+ }
+
+ init {
+ "*" style { boxSizing("border-box") }
+ "body" style {
+ margin(0.px); color(ink); fontFamily("Arial", "Helvetica", "sans-serif")
+ background("radial-gradient(circle at 90% 8%, #f7d5bf, transparent 38%), linear-gradient(145deg, #fff7e9, #f2dcc9 65%, #e9c9b6)")
+ }
+ ".${hero} h1" style {
+ margin(32.px, 0.px); font("400 clamp(64px, 8.8vw, 126px)/.98 $serif"); letterSpacing((-0.075).em)
+ }
+ ".${hero} em" style { fontWeight(400) }
+ ".${islandGrid} .${islandSection}" style {
+ minWidth(0.px); padding(52.px, 6.percent, 58.px); property("border-bottom", "0")
+ }
+ ".${islandGrid} .${islandSection} + .${islandSection}" style { property("border-left", "1px solid $rule") }
+ ".${islandGrid} .${islandHeading}" style { flexWrap(FlexWrap.Wrap); gap(12.px) }
+ ".${islandGrid} .${islandHeading} h2" style { fontSize(48.px) }
+ ".${islandGrid} .${islandBody}" style { padding(24.px) }
+ ".${islandHeading} h2" style {
+ margin(8.px, 0.px, 0.px); font("400 54px/1 $serif"); letterSpacing((-0.05).em)
+ }
+ ".${weightPreview}, .${sizePreview}" style {
+ display(DisplayStyle.Flex); alignItems(AlignItems.Center); justifyContent(JustifyContent.Center)
+ color(ink)
+ }
+ ".${controls} input" style {
+ width(100.percent); margin(0.px); property("accent-color", "$terracotta")
+ cursor("pointer")
+ }
+ ".${controls} input:focus-visible" style {
+ outline("3px solid $terracotta"); property("outline-offset", "4px")
+ }
+ ".${editButton}:hover" style { color(Color("#693626")) }
+ ".${editButton}:focus-visible, .${textInput}:focus-visible" style {
+ outline("3px solid $terracotta"); property("outline-offset", "4px")
+ }
+ ".${codeSection} h2" style {
+ margin(18.px, 0.px, 12.px); font("400 48px/1 $serif"); letterSpacing((-0.05).em)
+ }
+ ".${codeSection} > p:not(.${eyebrow})" style {
+ maxWidth(650.px); color(Color("#795e51")); fontSize(17.px)
+ lineHeight("1.55")
+ }
+ ".${codeSection} pre" style {
+ overflowX("auto"); margin(30.px, 0.px, 0.px); padding(28.px)
+ background("#f5e9db"); border(1.px, LineStyle.Solid, Color("#e4cdbd")); borderRadius(18.px)
+ color(ink); font("14px/1.7 ui-monospace, SFMono-Regular, Consolas, monospace")
+ }
+ media("(max-width: 1000px)") {
+ ".${islandGrid}" style { gridTemplateColumns("1fr") }
+ ".${islandGrid} .${islandSection} + .${islandSection}" style {
+ property("border-left", "0"); property("border-top", "1px solid $rule")
+ }
+ }
+ media("(max-width: 720px)") {
+ ".${page}" style { padding(0.px, 18.px, 55.px) }
+ ".${masthead} > span" style { display(DisplayStyle.None) }
+ ".${hero}" style { padding(78.px, 3.percent, 0.px) }
+ ".${hero} h1" style { property("font-size", "clamp(52px, 11vw, 80px)") }
+ ".${lead}" style { marginBottom(72.px) }
+ ".${paper}" style { borderRadius(22.px) }
+ ".${paperHeader}" style { flexWrap(FlexWrap.Wrap); padding(22.px, 6.percent) }
+ ".${islandSection}, .${codeSection}" style { padding(50.px, 6.percent) }
+ ".${islandHeading}" style { flexWrap(FlexWrap.Wrap) }
+ ".${islandBody}" style { padding(20.px) }
+ ".${weightPreview}" style { minHeight(200.px) }
+ ".${sizePreview}" style { minHeight(230.px); justifyContent(JustifyContent.Start) }
+ ".${controls}" style { gridTemplateColumns("auto 1fr auto"); gap(10.px) }
+ ".${codeSection} pre" style { padding(18.px); fontSize(12.px) }
+ }
+ media("(max-width: 430px)") {
+ ".${textControls}" style {
+ alignItems(AlignItems.Start); flexDirection(FlexDirection.Column); gap(4.px)
+ }
+ }
+ }
+}
diff --git a/html/eap/examples/hydration-islands/src/jvmMain/kotlin/Page.kt b/html/eap/examples/hydration-islands/src/jvmMain/kotlin/Page.kt
new file mode 100644
index 0000000000..6d419938d9
--- /dev/null
+++ b/html/eap/examples/hydration-islands/src/jvmMain/kotlin/Page.kt
@@ -0,0 +1,104 @@
+import androidx.compose.runtime.Composable
+import org.jetbrains.compose.web.HydrationRoot
+import org.jetbrains.compose.web.dom.*
+
+private val hydrationExample = """
+ // JVM: render the islands as part of the page.
+ Html {
+ Head { ... }
+ Body {
+ ...
+
+ HydrationRoot(640, Int::toString, hydrationId = "island-weight") {
+ WeightIsland(it)
+ }
+
+ HydrationRoot(80, Int::toString, hydrationId = "island-size") {
+ SizeIsland(it)
+ }
+
+ HydrationRoot("Type something worth setting.", { it }, hydrationId = "island-text") {
+ TextIsland(it)
+ }
+ }
+ }
+
+ // Browser: only needs to hydrate islands
+ hydrateRoot(String::toInt, hydrationId = "island-weight") {
+ WeightIsland(it)
+ }
+ hydrateRoot(String::toInt, hydrationId = "island-size") {
+ SizeIsland(it)
+ }
+ hydrateRoot({ it }, hydrationId = "island-text") {
+ TextIsland(it)
+ }
+""".trimIndent()
+
+@Composable
+fun HydrationIslandsPage() {
+ Main(attrs = { classes(PageStyles.page) }) {
+ Div(attrs = { classes(PageStyles.masthead) }) {
+ Div(attrs = { classes(PageStyles.brand) }) {
+ Span { Text("HYDRATION ISLANDS") }
+ }
+ Span { Text("COMPOSE HTML") }
+ }
+
+ Header(attrs = { classes(PageStyles.hero) }) {
+ P(attrs = { classes(PageStyles.eyebrow) }) { Text("SELECTIVE INTERACTION") }
+ H1 {
+ Text("Most of this page")
+ Br()
+ Em { Text("never wakes up.") }
+ }
+ P(attrs = { classes(PageStyles.lead) }) {
+ Text("Render the whole experience on the server.")
+ Br()
+ Text("Hydrate only the parts that need to respond.")
+ }
+ Div(attrs = { classes(PageStyles.heroFoot) }) {
+ Span { Text("SCROLL TO EXPLORE") }
+ Span { Text("↓") }
+ }
+ }
+
+ Article(attrs = { classes(PageStyles.paper) }) {
+ Header(attrs = { classes(PageStyles.paperHeader) }) {
+ Span { Text("A TYPE PLAYGROUND") }
+ Span { Text("STATIC HTML / THREE ISLANDS") }
+ }
+ Div(attrs = { classes(PageStyles.islandGrid) }) {
+ Section(attrs = { classes(PageStyles.islandSection) }) {
+ HydrationRoot(640, Int::toString, hydrationId = "island-weight") {
+ WeightIsland(it)
+ }
+ }
+
+ Section(attrs = { classes(PageStyles.islandSection) }) {
+ HydrationRoot(80, Int::toString, hydrationId = "island-size") {
+ SizeIsland(it)
+ }
+ }
+ }
+
+ Section(attrs = { classes(PageStyles.islandSection) }) {
+ IslandHeading("03", "Your words")
+ HydrationRoot("Type something worth setting.", { it }, hydrationId = "island-text") {
+ TextIsland(it)
+ }
+ }
+
+ Section(attrs = { classes(PageStyles.codeSection) }) {
+ P(attrs = { classes(PageStyles.eyebrow) }) { Text("HOW THE ISLANDS WORK") }
+ H2 { Text("One ID. One island.") }
+ P { Text("The server renders each island. The browser finds its ID and hydrates just that region.") }
+ Pre { Code { Text(hydrationExample) } }
+ }
+
+ Footer(attrs = { classes(PageStyles.paperFooter) }) {
+ Span { Text("COMPOSE HTML / HYDRATION ISLANDS") }
+ }
+ }
+ }
+}
diff --git a/html/eap/examples/hydration-islands/src/jvmMain/kotlin/Server.kt b/html/eap/examples/hydration-islands/src/jvmMain/kotlin/Server.kt
new file mode 100644
index 0000000000..70bd0b1d30
--- /dev/null
+++ b/html/eap/examples/hydration-islands/src/jvmMain/kotlin/Server.kt
@@ -0,0 +1,30 @@
+import org.jetbrains.compose.web.attributes.ScriptType
+import org.jetbrains.compose.web.attributes.src
+import org.jetbrains.compose.web.attributes.type
+import org.jetbrains.compose.web.css.Style
+import org.jetbrains.compose.web.dom.*
+import org.jetbrains.compose.web.renderHydratedDocument
+import java.io.File
+
+fun main(args: Array) {
+ val output = File(args.single())
+ output.parentFile.mkdirs()
+ output.writeText(renderHydratedDocument {
+ Html(attrs = { lang("en") }) {
+ Head {
+ Meta(attrs = { attr("charset", "utf-8") })
+ Meta(attrs = {
+ attr("name", "viewport")
+ attr("content", "width=device-width, initial-scale=1")
+ })
+ Title { Text("Hydration Islands in Compose HTML") }
+ Style(PageStyles)
+ }
+ Body {
+ HydrationIslandsPage()
+ Script(attrs = { type(ScriptType.Module); src("app.js") })
+ }
+ }
+ })
+ println("Rendered ${output.toURI()}")
+}
diff --git a/html/eap/examples/hydration-islands/src/webMain/kotlin/Client.kt b/html/eap/examples/hydration-islands/src/webMain/kotlin/Client.kt
new file mode 100644
index 0000000000..d9e6d0d1f1
--- /dev/null
+++ b/html/eap/examples/hydration-islands/src/webMain/kotlin/Client.kt
@@ -0,0 +1,7 @@
+import org.jetbrains.compose.web.hydrateRoot
+
+fun main() {
+ hydrateRoot(String::toInt, hydrationId = "island-weight") { WeightIsland(it) }
+ hydrateRoot(String::toInt, hydrationId = "island-size") { SizeIsland(it) }
+ hydrateRoot({ it }, hydrationId = "island-text") { TextIsland(it) }
+}
diff --git a/html/settings.gradle.kts b/html/settings.gradle.kts
index b302ae8f21..2ee02bb9be 100644
--- a/html/settings.gradle.kts
+++ b/html/settings.gradle.kts
@@ -106,6 +106,7 @@ if (composeHtmlEapEnabled) {
module(":internal-html-core-runtime-eap", "eap/internal-html-core-runtime")
module(":html-eap-example", "eap/examples/string-rendering")
module(":html-eap-hydration-example", "eap/examples/hydration")
+ module(":html-eap-hydration-islands-example", "eap/examples/hydration-islands")
}
if (extra["compose.web.tests.skip.benchmarks"]!!.toString().toBoolean() != true) {
diff --git a/html/test-utils/conf/configure-browser-tests.js b/html/test-utils/conf/configure-browser-tests.js
index 46d75861f7..3a7d9cde37 100644
--- a/html/test-utils/conf/configure-browser-tests.js
+++ b/html/test-utils/conf/configure-browser-tests.js
@@ -28,6 +28,7 @@ module.exports = function configureBrowserTests(config) {
for (const fixture of [
"ssr-hydration.html",
"ssr-hydration-state.html",
+ "ssr-hydration-islands.html",
"ssr-number-hydration.html",
"svg-ssr-hydration.html",
]) {