Skip to content
Merged
2 changes: 0 additions & 2 deletions compose/api/compose.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,6 @@ final class app.tinyui.node/PatchProblem { // app.tinyui.node/PatchProblem|null[
final class app.tinyui.node/UINode { // app.tinyui.node/UINode|null[0]
final val children // app.tinyui.node/UINode.children|{}children[0]
final fun <get-children>(): androidx.compose.runtime.snapshots/SnapshotStateList<app.tinyui.node/UINode> // app.tinyui.node/UINode.children.<get-children>|<get-children>(){}[0]
final val commands // app.tinyui.node/UINode.commands|{}commands[0]
final fun <get-commands>(): androidx.compose.runtime.snapshots/SnapshotStateList<app.tinyui.node/Command> // app.tinyui.node/UINode.commands.<get-commands>|<get-commands>(){}[0]
final val events // app.tinyui.node/UINode.events|{}events[0]
final fun <get-events>(): androidx.compose.runtime.snapshots/SnapshotStateMap<kotlin/String, kotlin/Boolean> // app.tinyui.node/UINode.events.<get-events>|<get-events>(){}[0]
final val id // app.tinyui.node/UINode.id|{}id[0]
Expand Down
162 changes: 96 additions & 66 deletions compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,115 +28,131 @@ class PatchProblem(val op: String, val reason: String) {
class NodeTree(private val registry: ComponentRegistry, private val report: (PatchProblem) -> Unit) {
val root = UINode(ROOT_ID, "root")
private val nodes = HashMap<Int, UINode>().apply { put(ROOT_ID, root) }
private val born = ArrayList<UINode>()

val size: Int get() = nodes.size

fun node(id: Int): UINode? = nodes[id]

/** Applies one flush atomically: other threads see the tree before or after, never in between. */
/**
* Applies one flush atomically: other threads see the tree before or after, never in between. Nothing leaves the
* snapshot while it is open: removed nodes' queues are cancelled, commands sent and problems reported only once the
* flush is applied, in that order. So a component never gets a command ahead of the props it came with, a flush that
* fails to apply leaves no trace beyond its report, and a sink hears of a flush once it is complete: if it writes to
* the tree from [report], that is a flush of its own, after this one.
*/
fun apply(json: String) {
val ops = try {
Json.parseToJsonElement(json).jsonArray
} catch (e: Exception) {
report(PatchProblem(json.take(200), "message is not a JSON array (${e.message})"))
return
}
Snapshot.withMutableSnapshot {
for (op in ops) applyOp(op)
for (node in born) {
node.created = true
registry.schema(node.type)?.props?.forEach { (key, spec) ->
if (spec.required && node.props[key] == null) report(PatchProblem("[\"c\",${node.id},\"${node.type}\"]", "required prop $key was not set in the creating flush"))
val flush = Flush()
try {
Snapshot.withMutableSnapshot {
for (op in ops) applyOp(op, flush)
for (node in flush.born) {
node.created = true
registry.schema(node.type)?.props?.forEach { (key, spec) ->
if (spec.required && node.props[key] == null) flush.problem("[\"c\",${node.id},\"${node.type}\"]", "required prop $key was not set in the creating flush")
}
}
}
born.clear()
} catch (t: Throwable) {
// what went wrong is still worth hearing; nothing else of this flush happens
for (p in flush.problems) report(p)
Comment thread
codeseerbot[bot] marked this conversation as resolved.
Resolved
throw t
}
// cancelled first: a command for a node removed in the same flush is dropped, not delivered
for (node in flush.removed) node.commands.cancel()
for ((node, command) in flush.sent) node.commands.trySend(command)
for (p in flush.problems) report(p)
}

/** Drops every node like `r` would: their queues close, so nothing waits on a node that is gone. */
fun clear() {
Comment thread
codeseerbot[bot] marked this conversation as resolved.
Resolved
for (node in nodes.values) if (node !== root) node.commands.cancel()
root.children.clear()
nodes.clear()
nodes[ROOT_ID] = root
}

private fun applyOp(op: JsonElement) {
val fields = op as? JsonArray ?: return report(PatchProblem(op.toString(), "op is not an array"))
val kind = fields.getOrNull(0)?.string() ?: return report(PatchProblem(op.toString(), "op without kind"))
private fun applyOp(op: JsonElement, flush: Flush) {
val fields = op as? JsonArray ?: return flush.problem(op.toString(), "op is not an array")
val kind = fields.getOrNull(0)?.string() ?: return flush.problem(op.toString(), "op without kind")
when (kind) {
"c" -> create(fields, op)
"p" -> setProp(fields, op)
"i" -> insert(fields, op)
"m" -> move(fields, op)
"r" -> remove(fields, op)
"x" -> command(fields, op)
else -> report(PatchProblem(op.toString(), "unknown op"))
"c" -> create(fields, op, flush)
"p" -> setProp(fields, op, flush)
"i" -> insert(fields, op, flush)
"m" -> move(fields, op, flush)
"r" -> remove(fields, op, flush)
"x" -> command(fields, op, flush)
else -> flush.problem(op.toString(), "unknown op")
}
}

private fun create(f: JsonArray, op: JsonElement) {
val id = f.int(1) ?: return report(PatchProblem(op.toString(), "c without id"))
val type = f.string(2) ?: return report(PatchProblem(op.toString(), "c without type"))
if (nodes.containsKey(id)) return report(PatchProblem(op.toString(), "id already exists"))
private fun create(f: JsonArray, op: JsonElement, flush: Flush) {
val id = f.int(1) ?: return flush.problem(op.toString(), "c without id")
val type = f.string(2) ?: return flush.problem(op.toString(), "c without type")
if (nodes.containsKey(id)) return flush.problem(op.toString(), "id already exists")
// the requested type stays on the node: Render falls back to Placeholder, which shows it in debug
if (registry.schema(type) == null) report(PatchProblem(op.toString(), "unknown component type, rendering Placeholder"))
nodes[id] = UINode(id, type).also { born += it }
if (registry.schema(type) == null) flush.problem(op.toString(), "unknown component type, rendering Placeholder")
nodes[id] = UINode(id, type).also { flush.born += it }
}

private fun setProp(f: JsonArray, op: JsonElement) {
val node = child(f, op, at = 1) ?: return
val key = f.string(2) ?: return report(PatchProblem(op.toString(), "p without key"))
private fun setProp(f: JsonArray, op: JsonElement, flush: Flush) {
val node = child(f, op, flush, at = 1) ?: return
val key = f.string(2) ?: return flush.problem(op.toString(), "p without key")
val value = f.getOrNull(3) ?: JsonNull
val schema = registry.schema(node.type) ?: return
if (key.length > 2 && key.startsWith("on") && key[2].isUpperCase()) {
if (key !in schema.events) return report(PatchProblem(op.toString(), "event not in schema of ${node.type}"))
val flag = (value as? JsonPrimitive)?.booleanOrNull ?: return report(PatchProblem(op.toString(), "event flag must be a boolean"))
if (key !in schema.events) return flush.problem(op.toString(), "event not in schema of ${node.type}")
val flag = (value as? JsonPrimitive)?.booleanOrNull ?: return flush.problem(op.toString(), "event flag must be a boolean")
if (flag) node.events[key] = true else node.events.remove(key)
return
}
val spec = schema.prop(key) ?: return report(PatchProblem(op.toString(), "prop not in schema of ${node.type}"))
if (spec.initial && node.created) return report(PatchProblem(op.toString(), "$key is an initial prop of ${node.type}: writes after creation are ignored"))
val spec = schema.prop(key) ?: return flush.problem(op.toString(), "prop not in schema of ${node.type}")
if (spec.initial && node.created) return flush.problem(op.toString(), "$key is an initial prop of ${node.type}: writes after creation are ignored")
if (value is JsonNull) {
if (spec.required && spec.default == null) return report(PatchProblem(op.toString(), "$key is required on ${node.type}; null is not allowed"))
if (spec.required && spec.default == null) return flush.problem(op.toString(), "$key is required on ${node.type}; null is not allowed")
node.props[key] = spec.default
return
}
val primitive = value as? JsonPrimitive ?: return report(PatchProblem(op.toString(), "prop value must be a scalar"))
val converted = spec.convert(primitive) ?: return report(PatchProblem(op.toString(), "cannot convert ${primitive} to ${spec.kind} for ${node.type}.$key"))
val primitive = value as? JsonPrimitive ?: return flush.problem(op.toString(), "prop value must be a scalar")
val converted = spec.convert(primitive) ?: return flush.problem(op.toString(), "cannot convert ${primitive} to ${spec.kind} for ${node.type}.$key")
node.props[key] = converted
}

private fun insert(f: JsonArray, op: JsonElement) {
val parent = f.int(1)?.let { nodes[it] } ?: return report(PatchProblem(op.toString(), "i: unknown parent"))
val node = child(f, op) ?: return
if (node.parent != null) return report(PatchProblem(op.toString(), "i: node already has a parent"))
if (node === parent || isAncestor(node, of = parent)) return report(PatchProblem(op.toString(), "i: would create a cycle"))
val index = clamp(f.int(3), parent.children.size, op) ?: return
private fun insert(f: JsonArray, op: JsonElement, flush: Flush) {
val parent = f.int(1)?.let { nodes[it] } ?: return flush.problem(op.toString(), "i: unknown parent")
val node = child(f, op, flush) ?: return
if (node.parent != null) return flush.problem(op.toString(), "i: node already has a parent")
if (node === parent || isAncestor(node, of = parent)) return flush.problem(op.toString(), "i: would create a cycle")
val index = clamp(f.int(3), parent.children.size, op, flush) ?: return
node.parent = parent
parent.children.add(index, node)
}

private fun move(f: JsonArray, op: JsonElement) {
val parent = f.int(1)?.let { nodes[it] } ?: return report(PatchProblem(op.toString(), "m: unknown parent"))
val node = child(f, op) ?: return
if (node.parent !== parent) return report(PatchProblem(op.toString(), "m: node is not a child of parent"))
val index = clamp(f.int(3), parent.children.size - 1, op) ?: return
private fun move(f: JsonArray, op: JsonElement, flush: Flush) {
val parent = f.int(1)?.let { nodes[it] } ?: return flush.problem(op.toString(), "m: unknown parent")
val node = child(f, op, flush) ?: return
if (node.parent !== parent) return flush.problem(op.toString(), "m: node is not a child of parent")
val index = clamp(f.int(3), parent.children.size - 1, op, flush) ?: return
parent.children.remove(node)
parent.children.add(index, node)
}

private fun remove(f: JsonArray, op: JsonElement) {
val node = child(f, op, at = 1) ?: return
private fun remove(f: JsonArray, op: JsonElement, flush: Flush) {
val node = child(f, op, flush, at = 1) ?: return
node.parent?.children?.remove(node)
node.parent = null
forget(node)
forget(node, flush)
}

/** Ops that act on a node as a child never target the root container. */
private fun child(f: JsonArray, op: JsonElement, at: Int = 2): UINode? {
val id = f.int(at) ?: run { report(PatchProblem(op.toString(), "op without node id")); return null }
val node = nodes[id] ?: run { report(PatchProblem(op.toString(), "unknown node $id")); return null }
if (node.id == ROOT_ID) { report(PatchProblem(op.toString(), "the root container is not a child")); return null }
private fun child(f: JsonArray, op: JsonElement, flush: Flush, at: Int = 2): UINode? {
val id = f.int(at) ?: run { flush.problem(op.toString(), "op without node id"); return null }
val node = nodes[id] ?: run { flush.problem(op.toString(), "unknown node $id"); return null }
if (node.id == ROOT_ID) { flush.problem(op.toString(), "the root container is not a child"); return null }
return node
}

Expand All @@ -146,30 +162,32 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat
return false
}

private fun command(f: JsonArray, op: JsonElement) {
val node = child(f, op, at = 1) ?: return
val name = f.string(2) ?: return report(PatchProblem(op.toString(), "x without name"))
private fun command(f: JsonArray, op: JsonElement, flush: Flush) {
val node = child(f, op, flush, at = 1) ?: return
val name = f.string(2) ?: return flush.problem(op.toString(), "x without name")
val schema = registry.schema(node.type) ?: return
val fields = schema.commands[name] ?: return report(PatchProblem(op.toString(), "command not in schema of ${node.type}"))
val fields = schema.commands[name] ?: return flush.problem(op.toString(), "command not in schema of ${node.type}")
val given = f.getOrNull(3) as? JsonObject ?: JsonObject(emptyMap())
val args = HashMap<String, Any?>()
for ((field, spec) in fields) {
val v = given[field] as? JsonPrimitive ?: return report(PatchProblem(op.toString(), "command $name needs $field"))
if (!spec.accepts(v)) return report(PatchProblem(op.toString(), "command $name: $field must be $spec"))
val v = given[field] as? JsonPrimitive ?: return flush.problem(op.toString(), "command $name needs $field")
if (!spec.accepts(v)) return flush.problem(op.toString(), "command $name: $field must be $spec")
args[field] = v.scalar()
}
node.commands.add(Command(name, args))
flush.sent += node to Command(name, args)
}

private fun forget(node: UINode) {
private fun forget(node: UINode, flush: Flush) {
nodes.remove(node.id)
for (child in node.children) forget(child)
// deleted for good (ids are never reused): its queue is cancelled once the flush is applied
flush.removed += node
for (child in node.children) forget(child, flush)
}

private fun clamp(index: Int?, size: Int, op: JsonElement): Int? {
if (index == null) { report(PatchProblem(op.toString(), "missing index")); return null }
private fun clamp(index: Int?, size: Int, op: JsonElement, flush: Flush): Int? {
if (index == null) { flush.problem(op.toString(), "missing index"); return null }
if (index < 0 || index > size) {
report(PatchProblem(op.toString(), "index $index out of [0, $size], clamped"))
flush.problem(op.toString(), "index $index out of [0, $size], clamped")
return index.coerceIn(0, size)
}
return index
Expand All @@ -185,3 +203,15 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat
else -> doubleOrNull
}
}

/** What one flush does outside the snapshot, once it is applied; the ops only run with one in hand. */
private class Flush {
val problems = ArrayList<PatchProblem>()
val born = ArrayList<UINode>()
val sent = ArrayList<Pair<UINode, Command>>()
val removed = ArrayList<UINode>()

fun problem(op: String, reason: String) {
problems += PatchProblem(op, reason)
}
}
9 changes: 7 additions & 2 deletions compose/src/commonMain/kotlin/app/tinyui/node/UINode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap
import kotlinx.coroutines.channels.Channel

/** One node of the page tree; the recomposition unit (docs/adr-003-kotlin-node-tree-and-registry.md). */
class UINode internal constructor(val id: Int, val type: String) {
Expand All @@ -12,8 +13,12 @@ class UINode internal constructor(val id: Int, val type: String) {
/** Events the JS side registered a handler for. */
val events: SnapshotStateMap<String, Boolean> = mutableStateMapOf()
val children: SnapshotStateList<UINode> = mutableStateListOf()
/** One-shot commands waiting for the component to consume them (`x` op). */
val commands: SnapshotStateList<Command> = mutableStateListOf()
/**
* One-shot commands for the component (`x` op), oldest first, each sent once its flush is applied. A queue, not
* snapshot state (docs/adr-004 §3.2): the engine thread sends while the main thread receives, and a state list
* written from both conflicts when either snapshot applies.
*/
internal val commands = Channel<Command>(Channel.UNLIMITED)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[建议] commands 从原先公开的 ReceiveChannel<Command> 改成了 internal Channel<Command>,生成的 compose.klib.api 也确认该公开 getter 已被移除。PR 描述声明的是其类型改为 ReceiveChannel<Command>,这会让按该说明迁移、或直接消费 UINode.commands 的外部组件直接无法编译。若不打算额外移除该 API,应保留公开的只读 ReceiveChannel getter,并将发送端维持为内部字段。

internal var parent: UINode? = null
/** Set once the creating flush is applied: `initial` props stop accepting writes. */
internal var created = false
Expand Down
7 changes: 1 addition & 6 deletions compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import app.tinyui.node.Command
Expand Down Expand Up @@ -51,11 +50,7 @@ fun NodeScope.Commands(handler: suspend (Command) -> Unit) {
// the effect outlives recompositions: without this it would keep the first handler and the props it closed over
val current by rememberUpdatedState(handler)
LaunchedEffect(node) {
snapshotFlow { node.commands.toList() }.collect { pending ->
if (pending.isEmpty()) return@collect
node.commands.removeAll(pending)
for (c in pending) current(c)
}
for (c in node.commands) current(c)
}
}

Expand Down
Loading
Loading