Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions html/eap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,16 @@ private const val HtmlDoctype = "<!doctype html>"
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<String>() else null
return HtmlDoctype + composeHtmlString(requireHtmlDocumentRoot = true) {
CompositionLocalProvider(
LocalHydratedDocumentContext provides true,
LocalHydrationIds provides seenHydrationIds,
LocalHtmlValidationMode provides htmlValidationMode(validateStrictly),
) {
content()
}
}
}

Expand All @@ -62,24 +66,38 @@ fun <T> HydrationRoot(
initialState: T,
serializeState: (T) -> String,
rootAttrs: AttrBuilderContext<HTMLDivElement>? = null,
hydrationId: String? = null,
content: @Composable ElementScope<HTMLDivElement>.(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)
}
Script(
content = InlineScript(serializedState.escapeForHydrationStateElement()),
attrs = {
hydrationProtocolAttr(HydrationStateAttribute, HydrationStateFormat)
if (hydrationId != null) {
hydrationProtocolAttr(HydrationForAttribute, hydrationId)
}
if (validationMode == HtmlValidationMode.Strict) {
hydrationProtocolAttr(HydrationValidationAttribute, HydrationValidationEnabled)
}
Expand All @@ -89,3 +107,4 @@ fun <T> HydrationRoot(
}

private val LocalHydratedDocumentContext = staticCompositionLocalOf { false }
private val LocalHydrationIds = staticCompositionLocalOf<MutableSet<String>?> { null }
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ 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"
internal val HydrationStateMimeType: String = ScriptType.TextPlain.typeStr
internal val HydrationProtocolAttributes = setOf(
HydrationRootAttribute,
HydrationStateAttribute,
HydrationForAttribute,
HydrationValidationAttribute,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,90 @@ class HydratedDocumentTest {
}

assertEquals(
"<!doctype html><html><head><title>Hydrated page</title></head><body>" +
"<div class=\"application\" data-compose-hydration-root=\"\">" +
"JVM state: 42</div>" +
"<script data-compose-hydration-state=\"escaped-text-v1\" type=\"text/plain\">" +
"{\"label\":\"JVM state\",\"count\":42}</script>" +
"<script nonce=\"request-nonce\" integrity=\"sha384-test\" " +
"crossorigin=\"anonymous\" type=\"module\" " +
"src=\"/application.js\"></script></body></html>",
"""<!doctype html><html><head><title>Hydrated page</title></head><body>""" +
"""<div class="application" data-compose-hydration-root="">JVM state: 42</div>""" +
"""<script data-compose-hydration-state="escaped-text-v1" type="text/plain">""" +
"""{"label":"JVM state","count":42}</script>""" +
"""<script nonce="request-nonce" integrity="sha384-test" """ +
"""crossorigin="anonymous" type="module" """ +
"""src="/application.js"></script></body></html>""",
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(
"""<!doctype html><html><body>Header""" +
"""<div data-compose-hydration-root="article">Article: article-state</div>""" +
"""<script data-compose-hydration-state="escaped-text-v1" """ +
"""data-compose-hydration-for="article" data-compose-hydration-validation="on" """ +
"""type="text/plain">article-state</script>""" +
"""Between""" +
"""<div data-compose-hydration-root="comments">Comments: comments-state</div>""" +
"""<script data-compose-hydration-state="escaped-text-v1" """ +
"""data-compose-hydration-for="comments" data-compose-hydration-validation="on" """ +
"""type="text/plain">comments-state</script>""" +
"""Footer</body></html>""",
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<IllegalArgumentException> { 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<IllegalArgumentException> {
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) {
Expand All @@ -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)
}

Expand Down Expand Up @@ -149,8 +221,8 @@ class HydratedDocumentTest {
}

assertEquals(
"<!doctype html><html><head><title>Static page</title></head>" +
"<body>Nothing to hydrate</body></html>",
"""<!doctype html><html><head><title>Static page</title></head>""" +
"""<body>Nothing to hydrate</body></html>""",
rendered,
)
assertFalse("data-compose-hydration-state" in rendered)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand All @@ -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")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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") }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Loading
Loading