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
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,23 @@

package org.jetbrains.compose.web

import androidx.compose.runtime.AbstractApplier
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ControlledComposition
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.ReusableComposition
import androidx.compose.runtime.snapshots.Snapshot
import kotlinx.coroutines.Dispatchers
import org.jetbrains.compose.web.dom.AttrsBuilderPool
import org.jetbrains.compose.web.dom.HtmlStringWriterContext
import org.jetbrains.compose.web.dom.LocalComposeHtmlContext
import org.jetbrains.compose.web.dom.StringComposeHtmlContext
import org.jetbrains.compose.web.dom.StringHtmlApplier
import org.jetbrains.compose.web.dom.StringHtmlElementNode
import org.jetbrains.compose.web.dom.StringHtmlNodeWrapper

/**
* Composes [content] once into an HTML string without creating browser DOM nodes.
* With no [key], the backing composition is disposed after the initial HTML is serialized.
* With a key, the root, composition, and recomposer are retained for later calls with that key.
* Keyed calls are not thread-safe, even with different keys. Callers must synchronize them.
* With no [key], the backing composition is disposed after the HTML has been written.
* With a key, the composition and attribute-builder storage are retained for later calls with
* that key. Keyed calls are not thread-safe, even with different keys.
* Snapshot state changes made while rendering are discarded afterwards.
* Coroutine effects such as `LaunchedEffect` do not run. `SideEffect` and
* `DisposableEffect` still execute.
Expand All @@ -38,8 +37,7 @@ import org.jetbrains.compose.web.dom.StringHtmlNodeWrapper
*
* @param hydratable whether to emit text-boundary markers required to hydrate adjacent `Text`
* nodes. Set to `false` when the output will not be hydrated.
* @param key when non-null, reuses a renderer across calls. Each render clears remembered
* state and effects but keeps matching HTML nodes. Keys and trees persist until process exit.
* @param key when non-null, reuses a composition across calls.
* @param validateStrictly overrides the default strict-validation setting for this render.
* @throws IllegalArgumentException if a raw-text element contains unsafe text or element children,
* serialized `noscript` contents contain a `</noscript>` end tag, or ordinary text, RCDATA,
Expand All @@ -52,34 +50,25 @@ fun composeHtmlToString(
validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict,
content: @Composable () -> Unit,
): String {
val contentWithValidation: @Composable () -> Unit = {
CompositionLocalProvider(
LocalHtmlValidationMode provides if (validateStrictly) {
HtmlValidationMode.Strict
} else {
HtmlValidationMode.Fast
}
) { content() }
val validatedContent: @Composable () -> Unit = {
CompositionLocalProvider(LocalHtmlValidationMode provides htmlValidationMode(validateStrictly)) {
content()
}
}
return if (key == null) {
composeHtmlTree(contentWithValidation) { tree -> tree.toHtmlString(hydratable) }
composeHtmlString(hydratable = hydratable, content = validatedContent)
} else {
composeReusableHtmlTree(key, contentWithValidation) { tree -> tree.toHtmlString(hydratable) }
composeReusableHtmlString(key, hydratable, validatedContent)
}
}

private val reusableHtmlCompositions = mutableMapOf<String, ReusableHtmlComposition>()

private class ReusableHtmlComposition {
val root = StringHtmlElementNode.root()
val recomposer = Recomposer(Dispatchers.Default).apply {
// Keep coroutine effects disabled for every render, as in the unkeyed path.
cancel()
}
val composition = ReusableComposition(
applier = StringHtmlApplier(StringHtmlNodeWrapper(root)),
parent = recomposer,
)
val recomposer = cancelledRecomposer()
val composition = ReusableComposition(UnitApplier(), recomposer)
val attrsBuilders = AttrsBuilderPool()
val output = StringBuilder()
var content: (@Composable () -> Unit)? = null
var rendering = false

Expand All @@ -89,29 +78,28 @@ private class ReusableHtmlComposition {
}
}

/** Renders with a keyed composition that remains available for later calls. */
internal fun <T> composeReusableHtmlTree(
private fun composeReusableHtmlString(
key: String,
hydratable: Boolean,
content: @Composable () -> Unit,
readTree: (StringHtmlElementNode) -> T,
): T {
): String {
val renderer = reusableHtmlCompositions.getOrPut(key, ::ReusableHtmlComposition)
check(!renderer.rendering) { "composeHtmlToString key \"$key\" is already rendering" }

renderer.output.setLength(0) // empty string output before next render
val context = HtmlStringWriterContext(hydratable, renderer.attrsBuilders, renderer.output)
val snapshot = Snapshot.takeMutableSnapshot()
renderer.rendering = true
renderer.content = content
try {
return snapshot.enter {
try {
renderer.composition.setContentWithReuse {
CompositionLocalProvider(
LocalComposeHtmlContext provides StringComposeHtmlContext
) {
CompositionLocalProvider(LocalComposeHtmlContext provides context) {
checkNotNull(renderer.content).invoke()
}
}
readTree(renderer.root)
context.finish(requireHtmlDocumentRoot = false)
} finally {
renderer.composition.deactivate()
}
Expand All @@ -127,34 +115,27 @@ internal fun <T> composeReusableHtmlTree(
}
}

/** Renders with a fresh composition that is disposed after this call. */
internal fun <T> composeHtmlTree(
/** Runs a fresh composition while the context writes markup directly to its output buffer. */
internal fun composeHtmlString(
hydratable: Boolean = true,
requireHtmlDocumentRoot: Boolean = false,
content: @Composable () -> Unit,
readTree: (StringHtmlElementNode) -> T,
): T {
val root = StringHtmlElementNode.root()
): String {
val context = HtmlStringWriterContext(hydratable)
val snapshot = Snapshot.takeMutableSnapshot()

return try {
snapshot.enter {
val recomposer = Recomposer(Dispatchers.Default).apply {
// Render the initial composition without starting coroutine effects.
cancel()
}
val composition = ControlledComposition(
applier = StringHtmlApplier(StringHtmlNodeWrapper(root)),
parent = recomposer,
)
val recomposer = cancelledRecomposer()
val composition = ControlledComposition(applier = UnitApplier(), parent = recomposer)

try {
composition.setContent {
CompositionLocalProvider(
LocalComposeHtmlContext provides StringComposeHtmlContext
) {
CompositionLocalProvider(LocalComposeHtmlContext provides context) {
content()
}
}
readTree(root)
context.finish(requireHtmlDocumentRoot)
} finally {
composition.dispose()
recomposer.close()
Expand All @@ -164,3 +145,15 @@ internal fun <T> composeHtmlTree(
snapshot.dispose()
}
}

/** Composes the initial content without starting coroutine effects. */
private fun cancelledRecomposer() = Recomposer(Dispatchers.Default).apply { cancel() }

/** [HtmlStringWriterContext] emits no Compose nodes, but Composition still requires an applier. */
private class UnitApplier : AbstractApplier<Unit>(Unit) {
override fun insertTopDown(index: Int, instance: Unit) = Unit
override fun insertBottomUp(index: Int, instance: Unit) = Unit
override fun remove(index: Int, count: Int) = Unit
override fun move(from: Int, to: Int, count: Int) = Unit
override fun onClear() = Unit
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ internal enum class HtmlValidationMode {

internal val LocalHtmlValidationMode = staticCompositionLocalOf { HtmlValidationMode.Fast }

internal fun htmlValidationMode(validateStrictly: Boolean): HtmlValidationMode =
if (validateStrictly) HtmlValidationMode.Strict else HtmlValidationMode.Fast

internal fun parseHtmlValidationMode(value: String?): HtmlValidationMode =
when (value?.trim()?.lowercase()) {
null, "", "false", "0" -> HtmlValidationMode.Fast
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.ElementScope
import org.jetbrains.compose.web.dom.InlineScript
import org.jetbrains.compose.web.dom.Script
import org.jetbrains.compose.web.dom.StringHtmlElementNode
import org.jetbrains.compose.web.dom.StringHtmlNode
import org.jetbrains.compose.web.dom.isEmptyText

private const val HtmlDoctype = "<!doctype html>"

Expand All @@ -34,24 +31,14 @@ private const val HtmlDoctype = "<!doctype html>"
fun renderHydratedDocument(
validateStrictly: Boolean = defaultHtmlValidationMode() == HtmlValidationMode.Strict,
content: @Composable () -> Unit,
): String = composeHtmlTree(
content = {
CompositionLocalProvider(
LocalHydratedDocumentContext provides true,
LocalHtmlValidationMode provides if (validateStrictly) {
HtmlValidationMode.Strict
} else {
HtmlValidationMode.Fast
},
) {
content()
}
},
readTree = { tree ->
validateHydratedDocument(tree)
HtmlDoctype + tree.toHtmlString(hydratable = true)
},
)
): String = HtmlDoctype + composeHtmlString(requireHtmlDocumentRoot = true) {
CompositionLocalProvider(
LocalHydratedDocumentContext provides true,
LocalHtmlValidationMode provides htmlValidationMode(validateStrictly),
) {
content()
}
}

/**
* Emits one browser-hydrated region of a document and its public initial state.
Expand Down Expand Up @@ -102,11 +89,3 @@ fun <T> HydrationRoot(
}

private val LocalHydratedDocumentContext = staticCompositionLocalOf { false }

private fun validateHydratedDocument(tree: StringHtmlElementNode) {
val documentChildren = tree.children.filterNot(StringHtmlNode::isEmptyText)
val html = documentChildren.singleOrNull() as? StringHtmlElementNode
require(html?.tagName == "html") {
"renderHydratedDocument content must produce exactly one html element"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,20 @@ open class AttrsScopeBuilder<TElement : Element>(
internal var allowsHydrationMismatch: Boolean = false
private set

internal fun reset() {
attributesStorage?.clear()
styleStorage?.let {
it.properties.clear()
it.variables.clear()
}
propertiesStorage?.clear()
classesStorage?.clear()
hydrationProtocolAttributesStorage?.clear()
eventsListenerScopeBuilder.reset()
refEffect = null
allowsHydrationMismatch = false
}

@ComposeWebInternalApi
override fun registerEventListener(listener: SyntheticEventListener<*>) {
eventsListenerScopeBuilder.registerEventListener(listener)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@ open class EventsListenerScopeBuilder : EventsListenerScope {
// Allocate listener storage only when an element registers one.
private var listeners: MutableList<SyntheticEventListener<*>>? = null

internal fun reset() {
listeners?.clear()
}

override fun registerEventListener(listener: SyntheticEventListener<*>) {
mutableListeners().add(listener)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,19 @@ private val HtmlTableChildNames = mapOf(

/** Rejects implied table wrappers and foster parenting before HTML leaves the string renderer. */
internal fun requireHtmlParserStableTableChild(parentTagName: String?, child: StringHtmlNode) {
val allowedChildren = HtmlTableChildNames[parentTagName] ?: return
val childTagName = when (child) {
is StringHtmlElementNode -> child.tagName
when (child) {
is StringHtmlElementNode -> requireHtmlParserStableTableElement(parentTagName, child.tagName)
is StringHtmlTextNode -> {
requireHtmlParserStableTableText(parentTagName, child.text)
return
}
is StringHtmlRawTextNode -> {
requireHtmlParserStableTableText(parentTagName, child.content.text)
return
}
}
}

internal fun requireHtmlParserStableTableElement(parentTagName: String?, childTagName: String?) {
val allowedChildren = HtmlTableChildNames[parentTagName] ?: return
if (childTagName in allowedChildren) return

val suggestion = when {
Expand All @@ -45,7 +46,8 @@ internal fun requireHtmlParserStableTableChild(parentTagName: String?, child: St
)
}

private fun requireHtmlParserStableTableText(parentTagName: String?, text: String) {
internal fun requireHtmlParserStableTableText(parentTagName: String?, text: String) {
if (parentTagName !in HtmlTableChildNames) return
require(text.all { it in AsciiWhitespaceCharacters }) {
"Non-whitespace text cannot be serialized directly inside <$parentTagName>; " +
"the HTML parser would move it outside the table. " +
Expand Down
Loading
Loading