diff --git a/html/eap/README.md b/html/eap/README.md index 8c5458f406..23e17e51f7 100644 --- a/html/eap/README.md +++ b/html/eap/README.md @@ -75,3 +75,12 @@ html/gradlew -p html -Pcompose.html.eap.enabled=true \ html/gradlew -p html -Pcompose.html.eap.enabled=true \ :html-eap-hydration-example:wasmJsBrowserDevelopmentRun ``` + +### Hydration islands type playground + +The page stays static except for three hydration roots. From the repository root, run: + +```shell +html/gradlew -p html -Pcompose.html.eap.enabled=true \ + :html-eap-hydration-islands-example:wasmJsBrowserDevelopmentRun +``` \ No newline at end of file diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydratedDocument.kt index 2b8bb0ab2c..c3d20e3ada 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 @@ -31,12 +31,16 @@ private const val HtmlDoctype = "" fun renderHydratedDocument( validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict, content: @Composable () -> Unit, -): String = HtmlDoctype + composeHtmlString(requireHtmlDocumentRoot = true) { - CompositionLocalProvider( - LocalHydratedDocumentContext provides true, - LocalHtmlValidationMode provides htmlValidationMode(validateStrictly), - ) { - content() +): String { + val seenHydrationIds = if (validateStrictly) mutableSetOf() else null + return HtmlDoctype + composeHtmlString(requireHtmlDocumentRoot = true) { + CompositionLocalProvider( + LocalHydratedDocumentContext provides true, + LocalHydrationIds provides seenHydrationIds, + LocalHtmlValidationMode provides htmlValidationMode(validateStrictly), + ) { + content() + } } } @@ -62,17 +66,28 @@ fun HydrationRoot( initialState: T, serializeState: (T) -> String, rootAttrs: AttrBuilderContext? = null, + hydrationId: String? = null, content: @Composable ElementScope.(T) -> Unit, ) { check(LocalHydratedDocumentContext.current) { "HydrationRoot must be called inside renderHydratedDocument" } + require(hydrationId == null || hydrationId.isNotBlank()) { + "hydrationId must not be blank" + } val validationMode = LocalHtmlValidationMode.current val serializedState = serializeState(initialState) + val seenHydrationIds = LocalHydrationIds.current Div(attrs = { rootAttrs?.invoke(this) - hydrationProtocolAttr(HydrationRootAttribute, "") + hydrationProtocolAttr(HydrationRootAttribute, hydrationId.orEmpty()) + + if (hydrationId != null && seenHydrationIds != null) { // only in strict mode + require(seenHydrationIds.add(hydrationId)) { + "Duplicate Compose hydrationId \"$hydrationId\"" + } + } }) { content(initialState) } @@ -80,6 +95,9 @@ fun HydrationRoot( content = InlineScript(serializedState.escapeForHydrationStateElement()), attrs = { hydrationProtocolAttr(HydrationStateAttribute, HydrationStateFormat) + if (hydrationId != null) { + hydrationProtocolAttr(HydrationForAttribute, hydrationId) + } if (validationMode == HtmlValidationMode.Strict) { hydrationProtocolAttr(HydrationValidationAttribute, HydrationValidationEnabled) } @@ -89,3 +107,4 @@ fun HydrationRoot( } private val LocalHydratedDocumentContext = staticCompositionLocalOf { false } +private val LocalHydrationIds = staticCompositionLocalOf?> { null } diff --git a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydrationProtocol.kt b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydrationProtocol.kt index 22933f45c7..5b054f1e69 100644 --- a/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydrationProtocol.kt +++ b/html/eap/core/src/commonMain/kotlin/org/jetbrains/compose/web/HydrationProtocol.kt @@ -7,6 +7,7 @@ import org.jetbrains.compose.web.attributes.ScriptType internal const val HydrationRootAttribute = "data-compose-hydration-root" internal const val HydrationStateAttribute = "data-compose-hydration-state" +internal const val HydrationForAttribute = "data-compose-hydration-for" internal const val HydrationValidationAttribute = "data-compose-hydration-validation" internal const val HydrationValidationEnabled = "on" internal const val HydrationStateFormat = "escaped-text-v1" @@ -14,6 +15,7 @@ internal val HydrationStateMimeType: String = ScriptType.TextPlain.typeStr internal val HydrationProtocolAttributes = setOf( HydrationRootAttribute, HydrationStateAttribute, + HydrationForAttribute, HydrationValidationAttribute, ) diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentTest.kt index cb525947b0..72b2cd3379 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/HydratedDocumentTest.kt @@ -74,18 +74,90 @@ class HydratedDocumentTest { } assertEquals( - "Hydrated page" + - "
" + - "JVM state: 42
" + - "" + - "", + """Hydrated page""" + + """
JVM state: 42
""" + + """""" + + """""", rendered, ) } + @Test + fun rendersMultipleNamedRootsWithTheirOwnStates() { + val rendered = renderHydratedDocument(validateStrictly = true) { + Html { + Body { + Text("Header") + HydrationRoot( + initialState = "article-state", + serializeState = { it }, + hydrationId = "article", + ) { Text("Article: $it") } + Text("Between") + HydrationRoot( + initialState = "comments-state", + serializeState = { it }, + hydrationId = "comments", + ) { Text("Comments: $it") } + Text("Footer") + } + } + } + + assertEquals( + """Header""" + + """
Article: article-state
""" + + """""" + + """Between""" + + """
Comments: comments-state
""" + + """""" + + """Footer""", + rendered, + ) + } + + @Test + fun duplicateNamedRootsFailOnlyInStrictMode() { + fun render(validateStrictly: Boolean): String = renderHydratedDocument( + validateStrictly = validateStrictly, + ) { + Html { + Body { + HydrationRoot(Unit, { "first" }, hydrationId = "cart") {} + HydrationRoot(Unit, { "second" }, hydrationId = "cart") {} + } + } + } + + val failure = assertFailsWith { render(validateStrictly = true) } + assertContains(failure.message.orEmpty(), """Duplicate Compose hydrationId "cart"""") + assertContains(render(validateStrictly = false), """data-compose-hydration-for="cart"""") + } + + @Test + fun rejectsBlankHydrationIds() { + listOf("", " \t ").forEach { hydrationId -> + val failure = assertFailsWith { + renderHydratedDocument { + Html { + Body { + HydrationRoot(Unit, { "null" }, hydrationId = hydrationId) {} + } + } + } + } + + assertContains(failure.message.orEmpty(), "hydrationId must not be blank") + } + } + @Test fun strictValidationIsTransportedOnlyWhenEnabled() { val strict = renderHydratedDocument(validateStrictly = true) { @@ -95,7 +167,7 @@ class HydratedDocumentTest { Html { Body { HydrationRoot(Unit, { "null" }) {} } } } - assertContains(strict, "data-compose-hydration-validation=\"on\"") + assertContains(strict, """data-compose-hydration-validation="on"""") assertFalse(HydrationValidationAttribute in fast) } @@ -149,8 +221,8 @@ class HydratedDocumentTest { } assertEquals( - "Static page" + - "Nothing to hydrate", + """Static page""" + + """Nothing to hydrate""", rendered, ) assertFalse("data-compose-hydration-state" in rendered) 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 977a82f7c0..796fbcf55a 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 @@ -25,12 +25,16 @@ internal fun SsrHydrationState.toJson(): String = @Composable internal fun SsrHydrationStateApplication( initialState: SsrHydrationState, + buttonId: String = SSR_HYDRATION_STATE_BUTTON_ID, + valueId: String = SSR_HYDRATION_STATE_VALUE_ID, ) { var count by remember(initialState) { mutableStateOf(initialState.count) } SsrHydrationStateContent( label = initialState.label, count = count, increment = { count++ }, + buttonId = buttonId, + valueId = valueId, ) } @@ -39,16 +43,18 @@ internal fun SsrHydrationStateContent( label: String, count: Int, increment: () -> Unit, + buttonId: String = SSR_HYDRATION_STATE_BUTTON_ID, + valueId: String = SSR_HYDRATION_STATE_VALUE_ID, ) { Button( attrs = { - id(SSR_HYDRATION_STATE_BUTTON_ID) + id(buttonId) onClick { increment() } } ) { Text("Increment") } - Span(attrs = { id(SSR_HYDRATION_STATE_VALUE_ID) }) { + Span(attrs = { id(valueId) }) { Text("$label: $count") } } diff --git a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SsrHydrationTest.kt b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SsrHydrationTest.kt index 41d5534165..e3cee999aa 100644 --- a/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SsrHydrationTest.kt +++ b/html/eap/core/src/commonTest/kotlin/org/jetbrains/compose/web/SsrHydrationTest.kt @@ -8,7 +8,9 @@ import org.jetbrains.compose.web.css.color import org.jetbrains.compose.web.css.opacity import org.jetbrains.compose.web.css.percent import org.jetbrains.compose.web.css.width +import org.jetbrains.compose.web.dom.Body import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Html import org.jetbrains.compose.web.dom.InlineScript import org.jetbrains.compose.web.dom.Script import org.jetbrains.compose.web.dom.Span @@ -25,6 +27,17 @@ internal const val SSR_HYDRATION_NORMALIZED_SCRIPT_CONTENT = "first\nsecond\nthi internal const val SSR_HYDRATION_RENDERED_AT_ID = "ssr-hydration-rendered-at" internal const val SSR_HYDRATION_SERVER_RENDERED_AT = "2026-08-31T10:15:30Z" internal const val SSR_HYDRATION_CLIENT_RENDERED_AT = "2026-08-31T10:15:42Z" +internal const val SSR_HYDRATION_ISLANDS_FIXTURE_URL = "./ssr-hydration-islands.html" +internal const val SSR_HYDRATION_CART_ID = "cart" +internal const val SSR_HYDRATION_ACCOUNT_ID = "account" +internal const val SSR_HYDRATION_ISLANDS_HEADER_ID = "ssr-hydration-islands-header" +internal const val SSR_HYDRATION_ISLANDS_FOOTER_ID = "ssr-hydration-islands-footer" + +internal fun ssrHydrationIslandButtonId(hydrationId: String): String = + "ssr-hydration-$hydrationId-button" + +internal fun ssrHydrationIslandValueId(hydrationId: String): String = + "ssr-hydration-$hydrationId-value" // Used by both JVM rendering and JS hydration @Composable @@ -65,3 +78,42 @@ internal fun SsrHydrationContent( Text("Rendered at $renderedAt") } } + +@Composable +internal fun SsrHydrationIsland( + hydrationId: String, + initialState: SsrHydrationState, +) { + SsrHydrationStateApplication( + initialState = initialState, + buttonId = ssrHydrationIslandButtonId(hydrationId), + valueId = ssrHydrationIslandValueId(hydrationId), + ) +} + +@Composable +internal fun SsrHydrationIslandsDocument( + cartState: SsrHydrationState, + accountState: SsrHydrationState, +) { + Html { + Body { + Span(attrs = { id(SSR_HYDRATION_ISLANDS_HEADER_ID) }) { Text("Header") } + HydrationRoot( + initialState = cartState, + serializeState = SsrHydrationState::toJson, + hydrationId = SSR_HYDRATION_CART_ID, + ) { initialState -> + SsrHydrationIsland(SSR_HYDRATION_CART_ID, initialState) + } + HydrationRoot( + initialState = accountState, + serializeState = SsrHydrationState::toJson, + hydrationId = SSR_HYDRATION_ACCOUNT_ID, + ) { initialState -> + SsrHydrationIsland(SSR_HYDRATION_ACCOUNT_ID, initialState) + } + Span(attrs = { id(SSR_HYDRATION_ISLANDS_FOOTER_ID) }) { Text("Footer") } + } + } +} 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 aada56d1e0..8ad7259f81 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 @@ -24,6 +24,7 @@ internal object SsrHydrationFixtureGenerator { SsrNumberHydrationContent(count = 0, increment = {}) } outputDirectory.writeHydrationStateFixture("ssr-hydration-state.html") + outputDirectory.writeHydrationIslandsFixture("ssr-hydration-islands.html") } private fun File.writeFixture(name: String, content: @Composable () -> Unit) { @@ -51,4 +52,14 @@ internal object SsrHydrationFixtureGenerator { resolve(name).writeText(rendered) } + + private fun File.writeHydrationIslandsFixture(name: String) { + val cartState = SsrHydrationState(label = "Cart loaded by JVM", count = 41) + val accountState = SsrHydrationState(label = "Account loaded by JVM", count = 7) + val rendered = renderHydratedDocument { + SsrHydrationIslandsDocument(cartState, accountState) + } + + resolve(name).writeText(rendered) + } } diff --git a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/HydrateComposable.web.kt b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/HydrateComposable.web.kt index 8200450bbe..a4730dfba9 100644 --- a/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/HydrateComposable.web.kt +++ b/html/eap/core/src/webMain/kotlin/org/jetbrains/compose/web/HydrateComposable.web.kt @@ -37,37 +37,39 @@ class HydrationStateException internal constructor( internal expect fun reportHydrationMismatch(mismatch: HydrationMismatchException) /** - * Finds the root and initial state emitted by [HydrationRoot], deserializes the state, and adopts - * the server-rendered DOM. [deserializeState] must match the serializer used on the server. Treat - * the result as an immutable snapshot. + * Hydrates one [HydrationRoot] in a server-rendered document. * - * The payload is untrusted, user-editable input. Use a safely configured deserializer, and validate - * and authorize any values sent back to a server. + * Pass the same [hydrationId] used by [HydrationRoot] to select a named root and its state element. + * A null [hydrationId] selects the unnamed pair. The selected root and state element must each be + * unique within [within], which defaults to the browser document. Pass a narrower [within] to + * limit the search to one subtree. * - * The state element remains in the document after hydration. Invalid or missing state throws - * [HydrationStateException] before composition starts, without invoking [onHydrationMismatch] or - * modifying the server-rendered DOM. + * Run this after both elements have been parsed. Place the bootstrap script after them, defer an + * external classic script or use a module script. * - * This function must run only after [HydrationRoot] and its state element have been parsed. Place - * the bootstrap script after [HydrationRoot], defer an external classic script, or use a module - * script. By default [within] is the browser document. Pass a narrower container to restrict - * protocol-element discovery to that subtree. + * [deserializeState] must match the serializer used on the server. The embedded state is public, + * user-editable input. Use a safely configured deserializer, and validate and authorize any values + * sent back to a server. Treat the deserialized state as an immutable snapshot. * - * The returned [Composition] owns the hydrated application and can be disposed when the - * application is no longer needed. - * The state element also selects strict validation when the server used - * `COMPOSE_HTML_VALIDATE_STRICTLY=true`; otherwise hydration retains initial server values. + * Hydration adopts the root's server-rendered children and leaves the state element in the document. + * The state element records the server's validation mode. In fast mode, hydration retains initial + * server values until a Compose update. * - * @throws HydrationStateException if the protocol elements or serialized state are invalid. + * A blank [hydrationId], a missing or duplicate pair, malformed protocol elements, or invalid + * serialized state throw [HydrationStateException] before composition starts. + * + * @throws HydrationStateException if [hydrationId] is blank, the selected pair is invalid or + * deserialization fails. */ fun hydrateRoot( deserializeState: (String) -> T, monotonicFrameClock: MonotonicFrameClock = DefaultMonotonicFrameClock, onHydrationMismatch: (HydrationMismatchException) -> Unit = ::reportHydrationMismatch, within: ParentNode = browserDocument, + hydrationId: String? = null, content: @Composable ElementScope.(T) -> Unit, ): Composition { - val protocol = findHydrationProtocol(within) + val protocol = findHydrationProtocol(within, hydrationId) val initialState = try { deserializeState(protocol.serializedState) } catch (failure: Throwable) { @@ -207,22 +209,41 @@ private inline fun Throwable.suppressCleanupFailure(cleanup: () -> Unit) { } } -private fun findHydrationProtocol(within: ParentNode): HydrationBootstrapData { - val rootElement = findUniqueProtocolElement( - within = within, - selector = "[$HydrationRootAttribute]", - description = "hydration root", - ) +private fun findHydrationProtocol(within: ParentNode, hydrationId: String?): HydrationBootstrapData { + if (hydrationId != null && hydrationId.isBlank()) { + invalidHydrationState("hydrationId must not be blank") + } + + val rootId = hydrationId.orEmpty() + val candidates = within.querySelectorAll("[$HydrationRootAttribute],[$HydrationStateAttribute]") + var matchingRoot: Element? = null + var matchingState: Element? = null + var rootCount = 0 + var stateCount = 0 + + for (index in 0 until candidates.length) { + val element = candidates.item(index) as Element + if (element.getAttribute(HydrationRootAttribute) == rootId) { + matchingRoot = element + rootCount++ + } + + // For unnamed roots, the state must have no hydration-for attribute + if (element.hasAttribute(HydrationStateAttribute) && + element.getAttribute(HydrationForAttribute) == hydrationId + ) { + matchingState = element + stateCount++ + } + } + + val rootElement = requireUniqueProtocolElement(matchingRoot, rootCount, "hydration root") if (!rootElement.tagName.equals("div", ignoreCase = true)) { invalidHydrationState("The Compose hydration root must be a
") } 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", ]) {