From 4696d02fd0c5de77f3e575d145964fd61fae2eca Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 29 Oct 2026 21:53:26 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E8=8A=82=E7=82=B9=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=98=9F=E5=88=97=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=B8=8E=20patch=20=E4=BA=89=E5=86=99=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=BB=BD=E5=BF=AB=E7=85=A7=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 命令原本放在 SnapshotStateList 里:引擎线程在 apply 的可变快照里追加,主线程消费时 removeAll, 两边写同一个列表,后提交的一方 SnapshotApplyConflictException,异常经 flush 回到 JS,页面 E2。 某宿主的注册页发码后先 clear 再 300ms 后 focus 验证码框(在弹层里、渲染较晚),偶发撞上。 改为 ADR-004 原本写的「队列,不进快照语义」:UINode.commands 是无界 Channel,引擎线程只发、 主线程只收;一个 flush 的命令在它的快照提交之后才发出,组件拿到命令时同批的 props 已经生效。 BREAKING:UINode.commands 由 SnapshotStateList 改为 ReceiveChannel; 自定义组件用 NodeScope.Commands 消费的不受影响。 Co-Authored-By: Claude Opus 5.5 --- compose/api/compose.klib.api | 2 +- .../kotlin/app/tinyui/node/NodeTree.kt | 8 ++++- .../kotlin/app/tinyui/node/UINode.kt | 11 +++++-- .../kotlin/app/tinyui/schema/NodeScope.kt | 7 +--- .../kotlin/app/tinyui/node/NodeTreeTest.kt | 33 +++++++++++++++++-- 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index d46c351..3e98edb 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -194,7 +194,7 @@ final class app.tinyui.node/UINode { // app.tinyui.node/UINode|null[0] final val children // app.tinyui.node/UINode.children|{}children[0] final fun (): androidx.compose.runtime.snapshots/SnapshotStateList // app.tinyui.node/UINode.children.|(){}[0] final val commands // app.tinyui.node/UINode.commands|{}commands[0] - final fun (): androidx.compose.runtime.snapshots/SnapshotStateList // app.tinyui.node/UINode.commands.|(){}[0] + final fun (): kotlinx.coroutines.channels/ReceiveChannel // app.tinyui.node/UINode.commands.|(){}[0] final val events // app.tinyui.node/UINode.events|{}events[0] final fun (): androidx.compose.runtime.snapshots/SnapshotStateMap // app.tinyui.node/UINode.events.|(){}[0] final val id // app.tinyui.node/UINode.id|{}id[0] diff --git a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt index 637ca78..c72beec 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt @@ -29,6 +29,8 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat val root = UINode(ROOT_ID, "root") private val nodes = HashMap().apply { put(ROOT_ID, root) } private val born = ArrayList() + // commands of the flush being applied, sent after it is: a component never gets one ahead of the props it came with + private val sent = ArrayList>() val size: Int get() = nodes.size @@ -42,6 +44,8 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat report(PatchProblem(json.take(200), "message is not a JSON array (${e.message})")) return } + // a flush that failed to apply sends nothing + sent.clear() Snapshot.withMutableSnapshot { for (op in ops) applyOp(op) for (node in born) { @@ -52,6 +56,8 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat } born.clear() } + for ((node, command) in sent) node.queue.trySend(command) + sent.clear() } fun clear() { @@ -158,7 +164,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat if (!spec.accepts(v)) return report(PatchProblem(op.toString(), "command $name: $field must be $spec")) args[field] = v.scalar() } - node.commands.add(Command(name, args)) + sent += node to Command(name, args) } private fun forget(node: UINode) { diff --git a/compose/src/commonMain/kotlin/app/tinyui/node/UINode.kt b/compose/src/commonMain/kotlin/app/tinyui/node/UINode.kt index 7026a1c..eeb04d4 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/node/UINode.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/node/UINode.kt @@ -4,6 +4,8 @@ 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 +import kotlinx.coroutines.channels.ReceiveChannel /** 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) { @@ -12,8 +14,13 @@ class UINode internal constructor(val id: Int, val type: String) { /** Events the JS side registered a handler for. */ val events: SnapshotStateMap = mutableStateMapOf() val children: SnapshotStateList = mutableStateListOf() - /** One-shot commands waiting for the component to consume them (`x` op). */ - val commands: SnapshotStateList = 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. + */ + val commands: ReceiveChannel get() = queue + internal val queue = Channel(Channel.UNLIMITED) internal var parent: UINode? = null /** Set once the creating flush is applied: `initial` props stop accepting writes. */ internal var created = false diff --git a/compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt b/compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt index 91bb85d..6ac5b6a 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt @@ -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 @@ -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) } } diff --git a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt index 070afe2..d3d0cf7 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt @@ -1,5 +1,6 @@ package app.tinyui.node +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.graphics.Color import app.tinyui.schema.ColorValue import androidx.compose.ui.unit.dp @@ -82,11 +83,39 @@ class NodeTreeTest { assertEquals(1, problems.size); assertTrue("initial prop" in problems.single().reason) problems.clear() tree.apply("""[["x",1,"setText",{"text":"z"}],["x",1,"setText",{}],["x",1,"setText",{"text":3}],["x",1,"focus",{}]]""") - assertEquals(listOf("setText", "focus"), tree.node(1)!!.commands.map { it.name }) - assertEquals("z", tree.node(1)!!.commands[0].args["text"]) + val sent = received(tree.node(1)!!) + assertEquals(listOf("setText", "focus"), sent.map { it.name }) + assertEquals("z", sent[0].args["text"]) assertEquals(2, problems.size) } + @Test + fun receivingCommandsNeverConflictsWithAFlushBeingApplied() { + tree.apply("""[["c",1,"TextField"],["i",0,1,0],["x",1,"setText",{"text":"a"}]]""") + val node = tree.node(1)!! + // the main thread takes a command inside its own snapshot while the engine thread applies the next flush: + // with commands as snapshot state both wrote the same list, and the later apply failed with a conflict + val main = Snapshot.takeMutableSnapshot() + val first = main.enter { node.commands.tryReceive().getOrNull() } + tree.apply("""[["x",1,"focus",{}]]""") + main.apply().check() + main.dispose() + assertEquals("setText", first?.name) + assertEquals(listOf("focus"), received(node).map { it.name }) + } + + @Test + fun aFlushSendsItsCommandsOnlyOnceItIsApplied() { + tree.apply("""[["c",1,"TextField"],["i",0,1,0]]""") + val node = tree.node(1)!! + tree.apply("""[["x",1,"focus",{}],["p",1,"placeholder","hint"]]""") + // what a component sees when the command arrives: the props of the same flush are already there + assertEquals("hint", node.props["placeholder"]) + assertEquals(listOf("focus"), received(node).map { it.name }) + } + + private fun received(node: UINode): List = generateSequence { node.commands.tryReceive().getOrNull() }.toList() + @Test fun requiredPropsAreCheckedWhenTheCreatingFlushEnds() { tree.apply("""[["c",1,"Text"],["i",0,1,0]]""") From 5cc1ea636a74404b9ab1a0480084d87c8a552b2f Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 30 Oct 2026 08:59:19 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E5=BE=85=E5=8F=91=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20apply=20=E5=86=85=E7=9A=84=E5=B1=80?= =?UTF-8?q?=E9=83=A8=E5=8F=98=E9=87=8F=EF=BC=9B=E5=88=A0=E9=99=A4=E7=9A=84?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E4=B8=8D=E5=86=8D=E6=94=B6=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=EF=BC=9B=E6=97=B6=E5=BA=8F=E6=B5=8B=E8=AF=95=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=85=88=E6=8F=90=E4=BA=A4=E5=90=8E=E9=80=81?= =?UTF-8?q?=E8=BE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 一次 apply 的命令只存在这一次调用里,宿主在 PageSink.error 里重入 apply 也不会清掉外层攒下的 - 节点被删除时取消它的队列:之前没消费的、同一批里稍后发给它的命令都丢弃 - 原测试在 apply 返回后才断言,命令早于提交送达也能通过;改为接收方在送达瞬间记录, 与快照的 apply 观察者比先后(把发送挪进提交之前,这条测试会失败) Co-Authored-By: Claude Opus 5.5 --- .../kotlin/app/tinyui/node/NodeTree.kt | 19 ++++++------- .../kotlin/app/tinyui/node/NodeTreeTest.kt | 27 ++++++++++++++++--- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt index c72beec..29ab6ec 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt @@ -29,8 +29,6 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat val root = UINode(ROOT_ID, "root") private val nodes = HashMap().apply { put(ROOT_ID, root) } private val born = ArrayList() - // commands of the flush being applied, sent after it is: a component never gets one ahead of the props it came with - private val sent = ArrayList>() val size: Int get() = nodes.size @@ -44,10 +42,11 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat report(PatchProblem(json.take(200), "message is not a JSON array (${e.message})")) return } - // a flush that failed to apply sends nothing - sent.clear() + // this flush's commands, sent once it is applied: a component never gets one ahead of the props it came with, + // and a flush that fails to apply sends none + val sent = ArrayList>() Snapshot.withMutableSnapshot { - for (op in ops) applyOp(op) + for (op in ops) applyOp(op, sent) for (node in born) { node.created = true registry.schema(node.type)?.props?.forEach { (key, spec) -> @@ -56,8 +55,8 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat } born.clear() } + // a node removed later in the flush has a cancelled queue: its commands are dropped there for ((node, command) in sent) node.queue.trySend(command) - sent.clear() } fun clear() { @@ -66,7 +65,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat nodes[ROOT_ID] = root } - private fun applyOp(op: JsonElement) { + private fun applyOp(op: JsonElement, sent: MutableList>) { 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")) when (kind) { @@ -75,7 +74,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat "i" -> insert(fields, op) "m" -> move(fields, op) "r" -> remove(fields, op) - "x" -> command(fields, op) + "x" -> command(fields, op, sent) else -> report(PatchProblem(op.toString(), "unknown op")) } } @@ -152,7 +151,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat return false } - private fun command(f: JsonArray, op: JsonElement) { + private fun command(f: JsonArray, op: JsonElement, sent: MutableList>) { val node = child(f, op, at = 1) ?: return val name = f.string(2) ?: return report(PatchProblem(op.toString(), "x without name")) val schema = registry.schema(node.type) ?: return @@ -169,6 +168,8 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat private fun forget(node: UINode) { nodes.remove(node.id) + // deleted for good (ids are never reused): commands still waiting, or sent later in this flush, are dropped + node.queue.cancel() for (child in node.children) forget(child) } diff --git a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt index d3d0cf7..785d37d 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt @@ -7,6 +7,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.tinyui.components.registerBuiltins import app.tinyui.schema.ComponentRegistry +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -105,13 +108,29 @@ class NodeTreeTest { } @Test - fun aFlushSendsItsCommandsOnlyOnceItIsApplied() { + fun aCommandArrivesOnlyAfterItsFlushIsApplied() = runTest { tree.apply("""[["c",1,"TextField"],["i",0,1,0]]""") val node = tree.node(1)!! + val events = ArrayList() + // runs on the spot, inside the send: if the command went out before the commit, it would come first + val receiver = launch(UnconfinedTestDispatcher(testScheduler)) { node.commands.receive(); events += "received" } + val applied = Snapshot.registerApplyObserver { changed, _ -> if (node.props in changed) events += "applied" } tree.apply("""[["x",1,"focus",{}],["p",1,"placeholder","hint"]]""") - // what a component sees when the command arrives: the props of the same flush are already there - assertEquals("hint", node.props["placeholder"]) - assertEquals(listOf("focus"), received(node).map { it.name }) + applied.dispose() + receiver.cancel() + assertEquals(listOf("applied", "received"), events) + } + + @Test + fun aRemovedNodeGetsNoMoreCommands() { + tree.apply("""[["c",1,"TextField"],["i",0,1,0],["c",2,"TextField"],["i",0,2,1]]""") + val first = tree.node(1)!! + val second = tree.node(2)!! + // one still waiting from an earlier flush, one sent in the flush that removes the node + tree.apply("""[["x",1,"focus",{}]]""") + tree.apply("""[["x",1,"setText",{"text":"a"}],["r",1],["x",2,"focus",{}]]""") + assertTrue(first.commands.isClosedForReceive) + assertEquals(listOf("focus"), received(second).map { it.name }) } private fun received(node: UINode): List = generateSequence { node.commands.tryReceive().getOrNull() }.toList() From 0f4280cdda885510ec9485c35785c26564f6a6aa Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 30 Oct 2026 09:14:59 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E4=B8=80=E6=89=B9=20patch=20?= =?UTF-8?q?=E7=9A=84=E5=BF=AB=E7=85=A7=E5=A4=96=E5=89=AF=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=9C=A8=E6=8F=90=E4=BA=A4=E5=90=8E=E5=8F=91?= =?UTF-8?q?=E7=94=9F=EF=BC=9B=E4=BB=8E=20report=20=E9=87=8D=E5=85=A5?= =?UTF-8?q?=E7=9A=84=20apply=20=E5=B9=B6=E5=85=A5=E8=A2=AB=E6=89=93?= =?UTF-8?q?=E6=96=AD=E7=9A=84=E9=82=A3=E4=B8=80=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeTree 记下正在应用的那一批(Flush:新建的节点、待发的命令、被删的节点),快照提交后 先取消被删节点的队列、再按顺序发命令;提交失败则什么都不发生。宿主在 PageSink.error 里 重入 apply 时不另起一批,命令排在外层之后、同样等外层提交后才送达。 新增测试覆盖重入:把重入分支改回「内层自己立刻发」,测试失败(setText 先到且读到旧 props)。 Co-Authored-By: Claude Opus 5.5 --- .../kotlin/app/tinyui/node/NodeTree.kt | 62 ++++++++++++------- .../kotlin/app/tinyui/node/NodeTreeTest.kt | 21 +++++++ 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt index 29ab6ec..a43b907 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt @@ -28,13 +28,19 @@ 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().apply { put(ROOT_ID, root) } - private val born = ArrayList() + // the flush being applied, null between flushes; an apply reentered from [report] joins it + private var flush: Flush? = null 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. What is not snapshot + * state waits for the commit: commands are sent, and removed nodes' queues cancelled, only once the flush is applied, + * so a component never gets a command ahead of the props it came with, and a flush that fails to apply leaves no + * trace there. An apply reentered from [report] (a sink writing to the tree) is part of the flush it interrupted. + */ fun apply(json: String) { val ops = try { Json.parseToJsonElement(json).jsonArray @@ -42,21 +48,28 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat report(PatchProblem(json.take(200), "message is not a JSON array (${e.message})")) return } - // this flush's commands, sent once it is applied: a component never gets one ahead of the props it came with, - // and a flush that fails to apply sends none - val sent = ArrayList>() - Snapshot.withMutableSnapshot { - for (op in ops) applyOp(op, sent) - 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")) + if (flush != null) { + Snapshot.withMutableSnapshot { for (op in ops) applyOp(op) } + return + } + val current = Flush() + flush = current + try { + Snapshot.withMutableSnapshot { + for (op in ops) applyOp(op) + for (node in current.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")) + } } } - born.clear() + } finally { + flush = null } - // a node removed later in the flush has a cancelled queue: its commands are dropped there - for ((node, command) in sent) node.queue.trySend(command) + // cancelled first: a command for a node removed in the same flush is dropped, not delivered + for (node in current.removed) node.queue.cancel() + for ((node, command) in current.sent) node.queue.trySend(command) } fun clear() { @@ -65,7 +78,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat nodes[ROOT_ID] = root } - private fun applyOp(op: JsonElement, sent: MutableList>) { + 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")) when (kind) { @@ -74,7 +87,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat "i" -> insert(fields, op) "m" -> move(fields, op) "r" -> remove(fields, op) - "x" -> command(fields, op, sent) + "x" -> command(fields, op) else -> report(PatchProblem(op.toString(), "unknown op")) } } @@ -85,7 +98,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat if (nodes.containsKey(id)) return report(PatchProblem(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 } + nodes[id] = UINode(id, type).also { flush!!.born += it } } private fun setProp(f: JsonArray, op: JsonElement) { @@ -151,7 +164,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat return false } - private fun command(f: JsonArray, op: JsonElement, sent: MutableList>) { + 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")) val schema = registry.schema(node.type) ?: return @@ -163,13 +176,13 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat if (!spec.accepts(v)) return report(PatchProblem(op.toString(), "command $name: $field must be $spec")) args[field] = v.scalar() } - sent += node to Command(name, args) + flush!!.sent += node to Command(name, args) } private fun forget(node: UINode) { nodes.remove(node.id) - // deleted for good (ids are never reused): commands still waiting, or sent later in this flush, are dropped - node.queue.cancel() + // 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) } @@ -192,3 +205,10 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat else -> doubleOrNull } } + +/** What one flush does outside the snapshot, after it commits. */ +private class Flush { + val born = ArrayList() + val sent = ArrayList>() + val removed = ArrayList() +} diff --git a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt index 785d37d..376e583 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt @@ -121,6 +121,27 @@ class NodeTreeTest { assertEquals(listOf("applied", "received"), events) } + @Test + fun anApplyReenteredFromReportJoinsTheFlushItInterrupted() = runTest { + var reentered = false + lateinit var reentrant: NodeTree + reentrant = NodeTree(ComponentRegistry().registerBuiltins()) { + if (!reentered) { reentered = true; reentrant.apply("""[["x",1,"setText",{"text":"inner"}]]""") } + } + reentrant.apply("""[["c",1,"TextField"],["i",0,1,0]]""") + val node = reentrant.node(1)!! + // each command as it arrives, with what the tree shows at that moment + val seen = ArrayList() + val receiver = launch(UnconfinedTestDispatcher(testScheduler)) { + for (c in node.commands) seen += "${c.name}:${node.props["placeholder"]}" + } + // the unknown command reports, the sink reenters; the placeholder comes after both + reentrant.apply("""[["x",1,"focus",{}],["x",1,"nope",{}],["p",1,"placeholder","hint"]]""") + receiver.cancel() + assertTrue(reentered) + assertEquals(listOf("focus:hint", "setText:hint"), seen) + } + @Test fun aRemovedNodeGetsNoMoreCommands() { tree.apply("""[["c",1,"TextField"],["i",0,1,0],["c",2,"TextField"],["i",0,2,1]]""") From ccae0c168953eee027d5278f221d932d9663bff0 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 30 Oct 2026 11:10:39 +0800 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=E9=97=AE=E9=A2=98=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E4=B9=9F=E7=AD=89=E5=BF=AB=E7=85=A7=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=90=8E=E5=86=8D=E5=81=9A=EF=BC=8Capply=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=87=8D=E5=85=A5=EF=BC=9BUINode.commands=20?= =?UTF-8?q?=E6=94=B6=E4=B8=BA=20internal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 快照打开期间不再有任何回调出去:问题、队列取消、命令发送统一在提交后按序发生。 sink 看到的永远是两批之间的完整树,从 report 里写树就是普通的一批,不需要特判; 原重入分支在必填属性检查时被重入创建节点会抛 ConcurrentModificationException,随之消失。 提交失败时已收集的问题仍在 finally 里上报。 commands 唯一的消费方是 NodeScope.Commands,公开 API 不再暴露 ReceiveChannel。 Co-Authored-By: Claude Fable 5.1 --- compose/api/compose.klib.api | 2 - .../kotlin/app/tinyui/node/NodeTree.kt | 90 ++++++++++--------- .../kotlin/app/tinyui/node/UINode.kt | 4 +- .../kotlin/app/tinyui/node/NodeTreeTest.kt | 36 +++++--- 4 files changed, 72 insertions(+), 60 deletions(-) diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index 3e98edb..31f7f8e 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -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 (): androidx.compose.runtime.snapshots/SnapshotStateList // app.tinyui.node/UINode.children.|(){}[0] - final val commands // app.tinyui.node/UINode.commands|{}commands[0] - final fun (): kotlinx.coroutines.channels/ReceiveChannel // app.tinyui.node/UINode.commands.|(){}[0] final val events // app.tinyui.node/UINode.events|{}events[0] final fun (): androidx.compose.runtime.snapshots/SnapshotStateMap // app.tinyui.node/UINode.events.|(){}[0] final val id // app.tinyui.node/UINode.id|{}id[0] diff --git a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt index a43b907..86d3a00 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt @@ -28,7 +28,7 @@ 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().apply { put(ROOT_ID, root) } - // the flush being applied, null between flushes; an apply reentered from [report] joins it + // the flush being applied, null between flushes private var flush: Flush? = null val size: Int get() = nodes.size @@ -36,10 +36,11 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat fun node(id: Int): UINode? = nodes[id] /** - * Applies one flush atomically: other threads see the tree before or after, never in between. What is not snapshot - * state waits for the commit: commands are sent, and removed nodes' queues cancelled, only once the flush is applied, - * so a component never gets a command ahead of the props it came with, and a flush that fails to apply leaves no - * trace there. An apply reentered from [report] (a sink writing to the tree) is part of the flush it interrupted. + * Applies one flush atomically: other threads see the tree before or after, never in between. Nothing leaves the + * snapshot while it is open: problems are reported, removed nodes' queues cancelled and commands sent only once the + * flush is applied. 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 sees the tree between flushes: if it writes to the tree from + * [report], that is a flush of its own. */ fun apply(json: String) { val ops = try { @@ -48,10 +49,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat report(PatchProblem(json.take(200), "message is not a JSON array (${e.message})")) return } - if (flush != null) { - Snapshot.withMutableSnapshot { for (op in ops) applyOp(op) } - return - } + check(flush == null) { "apply is not reentrant: one thread writes the tree, one flush at a time" } val current = Flush() flush = current try { @@ -60,16 +58,23 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat for (node in current.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")) + if (spec.required && node.props[key] == null) problem("[\"c\",${node.id},\"${node.type}\"]", "required prop $key was not set in the creating flush") } } } } finally { flush = null + // also when the snapshot failed to apply: what went wrong is still worth hearing + for (p in current.problems) report(p) } // cancelled first: a command for a node removed in the same flush is dropped, not delivered - for (node in current.removed) node.queue.cancel() - for ((node, command) in current.sent) node.queue.trySend(command) + for (node in current.removed) node.commands.cancel() + for ((node, command) in current.sent) node.commands.trySend(command) + } + + /** Recorded for the flush being applied, reported once it is. */ + private fun problem(op: String, reason: String) { + flush!!.problems += PatchProblem(op, reason) } fun clear() { @@ -79,8 +84,8 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat } 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")) + val fields = op as? JsonArray ?: return problem(op.toString(), "op is not an array") + val kind = fields.getOrNull(0)?.string() ?: return problem(op.toString(), "op without kind") when (kind) { "c" -> create(fields, op) "p" -> setProp(fields, op) @@ -88,56 +93,56 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat "m" -> move(fields, op) "r" -> remove(fields, op) "x" -> command(fields, op) - else -> report(PatchProblem(op.toString(), "unknown op")) + else -> 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")) + val id = f.int(1) ?: return problem(op.toString(), "c without id") + val type = f.string(2) ?: return problem(op.toString(), "c without type") + if (nodes.containsKey(id)) return 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")) + if (registry.schema(type) == null) 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")) + val key = f.string(2) ?: return 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 problem(op.toString(), "event not in schema of ${node.type}") + val flag = (value as? JsonPrimitive)?.booleanOrNull ?: return 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 problem(op.toString(), "prop not in schema of ${node.type}") + if (spec.initial && node.created) return 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 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 problem(op.toString(), "prop value must be a scalar") + val converted = spec.convert(primitive) ?: return 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 parent = f.int(1)?.let { nodes[it] } ?: return problem(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")) + if (node.parent != null) return problem(op.toString(), "i: node already has a parent") + if (node === parent || isAncestor(node, of = parent)) return problem(op.toString(), "i: would create a cycle") val index = clamp(f.int(3), parent.children.size, op) ?: 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 parent = f.int(1)?.let { nodes[it] } ?: return problem(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")) + if (node.parent !== parent) return problem(op.toString(), "m: node is not a child of parent") val index = clamp(f.int(3), parent.children.size - 1, op) ?: return parent.children.remove(node) parent.children.add(index, node) @@ -152,9 +157,9 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat /** 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 } + val id = f.int(at) ?: run { problem(op.toString(), "op without node id"); return null } + val node = nodes[id] ?: run { problem(op.toString(), "unknown node $id"); return null } + if (node.id == ROOT_ID) { problem(op.toString(), "the root container is not a child"); return null } return node } @@ -166,14 +171,14 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat 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")) + val name = f.string(2) ?: return 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 problem(op.toString(), "command not in schema of ${node.type}") val given = f.getOrNull(3) as? JsonObject ?: JsonObject(emptyMap()) val args = HashMap() 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 problem(op.toString(), "command $name needs $field") + if (!spec.accepts(v)) return problem(op.toString(), "command $name: $field must be $spec") args[field] = v.scalar() } flush!!.sent += node to Command(name, args) @@ -187,9 +192,9 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat } private fun clamp(index: Int?, size: Int, op: JsonElement): Int? { - if (index == null) { report(PatchProblem(op.toString(), "missing index")); return null } + if (index == null) { problem(op.toString(), "missing index"); return null } if (index < 0 || index > size) { - report(PatchProblem(op.toString(), "index $index out of [0, $size], clamped")) + problem(op.toString(), "index $index out of [0, $size], clamped") return index.coerceIn(0, size) } return index @@ -206,8 +211,9 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat } } -/** What one flush does outside the snapshot, after it commits. */ +/** What one flush does outside the snapshot, once it is applied. */ private class Flush { + val problems = ArrayList() val born = ArrayList() val sent = ArrayList>() val removed = ArrayList() diff --git a/compose/src/commonMain/kotlin/app/tinyui/node/UINode.kt b/compose/src/commonMain/kotlin/app/tinyui/node/UINode.kt index eeb04d4..c33dbf0 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/node/UINode.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/node/UINode.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateMap import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel /** 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) { @@ -19,8 +18,7 @@ class UINode internal constructor(val id: Int, val type: String) { * 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. */ - val commands: ReceiveChannel get() = queue - internal val queue = Channel(Channel.UNLIMITED) + internal val commands = Channel(Channel.UNLIMITED) internal var parent: UINode? = null /** Set once the creating flush is applied: `initial` props stop accepting writes. */ internal var created = false diff --git a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt index 376e583..1d64fc8 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt @@ -122,24 +122,34 @@ class NodeTreeTest { } @Test - fun anApplyReenteredFromReportJoinsTheFlushItInterrupted() = runTest { + fun problemsAreReportedOnceTheFlushIsApplied() { + val events = ArrayList() + lateinit var reporting: NodeTree + // what the sink sees of the tree at the moment it hears about a problem + reporting = NodeTree(ComponentRegistry().registerBuiltins()) { events += "reported:${reporting.node(1)!!.props["placeholder"]}" } + reporting.apply("""[["c",1,"TextField"],["i",0,1,0]]""") + val node = reporting.node(1)!! + val applied = Snapshot.registerApplyObserver { changed, _ -> if (node.props in changed) events += "applied" } + // the bad op comes first in the flush, the sink hears of it after the commit, with the whole flush in place + reporting.apply("""[["x",1,"nope",{}],["p",1,"placeholder","hint"]]""") + applied.dispose() + assertEquals(listOf("applied", "reported:hint"), events) + } + + @Test + fun aSinkWritingToTheTreeFromReportGetsAFlushOfItsOwn() { var reentered = false lateinit var reentrant: NodeTree reentrant = NodeTree(ComponentRegistry().registerBuiltins()) { - if (!reentered) { reentered = true; reentrant.apply("""[["x",1,"setText",{"text":"inner"}]]""") } - } - reentrant.apply("""[["c",1,"TextField"],["i",0,1,0]]""") - val node = reentrant.node(1)!! - // each command as it arrives, with what the tree shows at that moment - val seen = ArrayList() - val receiver = launch(UnconfinedTestDispatcher(testScheduler)) { - for (c in node.commands) seen += "${c.name}:${node.props["placeholder"]}" + problems += it + // reported for the missing required prop; the sink answers by adding a node + if (!reentered) { reentered = true; reentrant.apply("""[["c",2,"Text"],["p",2,"text","x"],["i",0,2,1]]""") } } - // the unknown command reports, the sink reenters; the placeholder comes after both - reentrant.apply("""[["x",1,"focus",{}],["x",1,"nope",{}],["p",1,"placeholder","hint"]]""") - receiver.cancel() + reentrant.apply("""[["c",1,"Text"],["i",0,1,0]]""") assertTrue(reentered) - assertEquals(listOf("focus:hint", "setText:hint"), seen) + assertEquals(listOf(1, 2), ids(reentrant.root)) + assertEquals("x", reentrant.node(2)!!.props["text"]) + assertEquals("required prop text was not set in the creating flush", problems.single().reason) } @Test From de14c1fb63ea19b2e5040a626909a716f27a6c83 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 30 Oct 2026 11:19:08 +0800 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20Flush=20=E4=BD=9C=E4=B8=BA?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=A9=BF=E8=BF=87=E5=90=84=20op=20=E6=96=B9?= =?UTF-8?q?=E6=B3=95=EF=BC=8C=E5=8E=BB=E6=8E=89=E5=8F=AF=E7=A9=BA=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=92=8C=20!!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「操作只能在一批里跑」从注释变成签名约束:没有 Flush 在手就调不到 create/setProp 等, apply 内的 check 也随之不需要。problem 收进 Flush 本身。 Co-Authored-By: Claude Fable 5.1 --- .../kotlin/app/tinyui/node/NodeTree.kt | 145 +++++++++--------- 1 file changed, 69 insertions(+), 76 deletions(-) diff --git a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt index 86d3a00..546c2e8 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt @@ -28,9 +28,6 @@ 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().apply { put(ROOT_ID, root) } - // the flush being applied, null between flushes - private var flush: Flush? = null - val size: Int get() = nodes.size fun node(id: Int): UINode? = nodes[id] @@ -49,32 +46,24 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat report(PatchProblem(json.take(200), "message is not a JSON array (${e.message})")) return } - check(flush == null) { "apply is not reentrant: one thread writes the tree, one flush at a time" } - val current = Flush() - flush = current + val flush = Flush() try { Snapshot.withMutableSnapshot { - for (op in ops) applyOp(op) - for (node in current.born) { + 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) problem("[\"c\",${node.id},\"${node.type}\"]", "required prop $key was not set in the creating flush") + if (spec.required && node.props[key] == null) flush.problem("[\"c\",${node.id},\"${node.type}\"]", "required prop $key was not set in the creating flush") } } } } finally { - flush = null // also when the snapshot failed to apply: what went wrong is still worth hearing - for (p in current.problems) report(p) + for (p in flush.problems) report(p) } // cancelled first: a command for a node removed in the same flush is dropped, not delivered - for (node in current.removed) node.commands.cancel() - for ((node, command) in current.sent) node.commands.trySend(command) - } - - /** Recorded for the flush being applied, reported once it is. */ - private fun problem(op: String, reason: String) { - flush!!.problems += PatchProblem(op, reason) + for (node in flush.removed) node.commands.cancel() + for ((node, command) in flush.sent) node.commands.trySend(command) } fun clear() { @@ -83,83 +72,83 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat nodes[ROOT_ID] = root } - private fun applyOp(op: JsonElement) { - val fields = op as? JsonArray ?: return problem(op.toString(), "op is not an array") - val kind = fields.getOrNull(0)?.string() ?: return problem(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 -> problem(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 problem(op.toString(), "c without id") - val type = f.string(2) ?: return problem(op.toString(), "c without type") - if (nodes.containsKey(id)) return problem(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) problem(op.toString(), "unknown component type, rendering Placeholder") - nodes[id] = UINode(id, type).also { flush!!.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 problem(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 problem(op.toString(), "event not in schema of ${node.type}") - val flag = (value as? JsonPrimitive)?.booleanOrNull ?: return problem(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 problem(op.toString(), "prop not in schema of ${node.type}") - if (spec.initial && node.created) return problem(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 problem(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 problem(op.toString(), "prop value must be a scalar") - val converted = spec.convert(primitive) ?: return problem(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 problem(op.toString(), "i: unknown parent") - val node = child(f, op) ?: return - if (node.parent != null) return problem(op.toString(), "i: node already has a parent") - if (node === parent || isAncestor(node, of = parent)) return problem(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 problem(op.toString(), "m: unknown parent") - val node = child(f, op) ?: return - if (node.parent !== parent) return problem(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 { problem(op.toString(), "op without node id"); return null } - val node = nodes[id] ?: run { problem(op.toString(), "unknown node $id"); return null } - if (node.id == ROOT_ID) { problem(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 } @@ -169,32 +158,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 problem(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 problem(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() for ((field, spec) in fields) { - val v = given[field] as? JsonPrimitive ?: return problem(op.toString(), "command $name needs $field") - if (!spec.accepts(v)) return problem(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() } - flush!!.sent += node to 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) // 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.removed += node + for (child in node.children) forget(child, flush) } - private fun clamp(index: Int?, size: Int, op: JsonElement): Int? { - if (index == null) { problem(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) { - problem(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 @@ -211,10 +200,14 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat } } -/** What one flush does outside the snapshot, once it is applied. */ +/** 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() val born = ArrayList() val sent = ArrayList>() val removed = ArrayList() + + fun problem(op: String, reason: String) { + problems += PatchProblem(op, reason) + } } From 54352adca95ddd2e04d067ff9f5b8da8d5f4491a Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 30 Oct 2026 11:39:20 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E4=B8=80=E6=89=B9=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E5=9C=A8=E5=AE=83=E7=9A=84=E5=91=BD=E4=BB=A4=E5=8F=91?= =?UTF-8?q?=E5=87=BA=E4=B9=8B=E5=90=8E=E5=86=8D=E4=B8=8A=E6=8A=A5=EF=BC=9B?= =?UTF-8?q?clear()=20=E4=B9=9F=E5=85=B3=E6=8E=89=E8=A2=AB=E4=B8=A2?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E7=9A=84=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sink 在 report 里重入写树时,它发的命令现在排在触发它的那一批之后,顺序不再倒置。 快照提交失败走单独路径:只上报,不发命令。clear() 与 r 操作的命令终止语义对齐。 Co-Authored-By: Claude Fable 5.1 --- .../kotlin/app/tinyui/node/NodeTree.kt | 16 ++++++++++------ .../kotlin/app/tinyui/node/NodeTreeTest.kt | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt index 546c2e8..4f72ca7 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/node/NodeTree.kt @@ -34,10 +34,10 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat /** * Applies one flush atomically: other threads see the tree before or after, never in between. Nothing leaves the - * snapshot while it is open: problems are reported, removed nodes' queues cancelled and commands sent only once the - * flush is applied. 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 sees the tree between flushes: if it writes to the tree from - * [report], that is a flush of its own. + * 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 { @@ -57,16 +57,20 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat } } } - } finally { - // also when the snapshot failed to apply: what went wrong is still worth hearing + } catch (t: Throwable) { + // what went wrong is still worth hearing; nothing else of this flush happens for (p in flush.problems) report(p) + 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() { + for (node in nodes.values) if (node !== root) node.commands.cancel() root.children.clear() nodes.clear() nodes[ROOT_ID] = root diff --git a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt index 1d64fc8..134856e 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/node/NodeTreeTest.kt @@ -152,6 +152,20 @@ class NodeTreeTest { assertEquals("required prop text was not set in the creating flush", problems.single().reason) } + @Test + fun aSinkHearsOfAFlushOnlyOnceItsCommandsAreSent() { + var reentered = false + lateinit var reentrant: NodeTree + reentrant = NodeTree(ComponentRegistry().registerBuiltins()) { + if (!reentered) { reentered = true; reentrant.apply("""[["x",1,"setText",{"text":"inner"}]]""") } + } + reentrant.apply("""[["c",1,"TextField"],["i",0,1,0]]""") + // the bad op reports after the flush is complete: the sink's own command queues up behind this flush's + reentrant.apply("""[["x",1,"focus",{}],["x",1,"nope",{}]]""") + assertTrue(reentered) + assertEquals(listOf("focus", "setText"), received(reentrant.node(1)!!).map { it.name }) + } + @Test fun aRemovedNodeGetsNoMoreCommands() { tree.apply("""[["c",1,"TextField"],["i",0,1,0],["c",2,"TextField"],["i",0,2,1]]""")