diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index 9b847ab..b897407 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -50,6 +50,11 @@ kotlin { iosMain.dependencies { implementation(libs.ktor.client.darwin) } + // composition-level tests run on the simulator; the Android host tests have no window + iosTest.dependencies { + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + } commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) diff --git a/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt b/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt index cde3279..a2371f6 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt @@ -263,8 +263,9 @@ private fun boxAlignment(name: String?): Alignment = when (name) { */ @Composable private fun TextFieldComponent(scope: NodeScope) { - val maxLength = scope.get("maxLength")?.toInt()?.takeIf { it > 0 } - fun clip(t: String) = clipTo(t, maxLength) + // read when used, not captured: a command runs before the recomposition that would refresh a captured value + fun clip(t: String) = clipTo(t, scope.get("maxLength")?.toInt()?.takeIf { it > 0 }) + val maxLength = scope.get("maxLength") var text by retain { mutableStateOf(clip(scope.get("initialText") ?: "")) } var focused by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } @@ -330,19 +331,22 @@ private fun TextFieldComponent(scope: NodeScope) { */ @Composable private fun CodeFieldComponent(scope: NodeScope) { - val length = (scope.get("length")?.toInt() ?: 6).coerceIn(1, MAX_CODE_LENGTH) + fun length() = (scope.get("length")?.toInt() ?: 6).coerceIn(1, MAX_CODE_LENGTH) + val length = length() var value by retain { mutableStateOf(TextFieldValue("")) } val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current var focused by remember { mutableStateOf(false) } // [force]: commands report onChange even when nothing changed, like TextField.setText fun update(raw: String, force: Boolean = false) { - val t = raw.filter { it in '0'..'9' }.take(length) + // the live prop, like TextField.clip: a command may run before this composition saw a new length + val max = length() + val t = raw.filter { it in '0'..'9' }.take(max) val changed = t != value.text value = TextFieldValue(t, selection = TextRange(t.length)) if (!changed && !force) return if (scope.has("onChange")) scope.dispatch("onChange", payload("text", t)) - if (changed && t.length == length && scope.has("onComplete")) scope.dispatch("onComplete", payload("text", t)) + if (changed && t.length == max && scope.has("onComplete")) scope.dispatch("onComplete", payload("text", t)) } // fewer cells drop the digits that no longer fit at once, rather than keeping them hidden until the next edit LaunchedEffect(length) { if (value.text.length > length) update(value.text) } diff --git a/compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt b/compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt index 6ac5b6a..a8fe5fb 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt @@ -43,7 +43,11 @@ interface NodeScope { fun RenderChild(child: UINode) } -/** Consumes this node's commands as they arrive, oldest first; one-shot, after composition (docs/adr-004 §3.2). */ +/** + * Consumes this node's commands as they arrive, oldest first; one-shot, after composition (docs/adr-004 §3.2). + * A command arrives once its flush is applied, but before the recomposition that flush causes: the handler must read + * the props it depends on through [NodeScope.get] when it runs, not use values captured during composition. + */ @Composable fun NodeScope.Commands(handler: suspend (Command) -> Unit) { val node = node diff --git a/compose/src/iosTest/kotlin/app/tinyui/components/CommandPropsTest.kt b/compose/src/iosTest/kotlin/app/tinyui/components/CommandPropsTest.kt new file mode 100644 index 0000000..4b872ba --- /dev/null +++ b/compose/src/iosTest/kotlin/app/tinyui/components/CommandPropsTest.kt @@ -0,0 +1,63 @@ +package app.tinyui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runComposeUiTest +import app.tinyui.node.NodeTree +import app.tinyui.node.PatchProblem +import app.tinyui.node.UINode +import app.tinyui.schema.ComponentRegistry +import app.tinyui.schema.NodeScope +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** A command arrives after its flush is applied but before the recomposition it causes (docs/components.md §4). */ +@OptIn(ExperimentalTestApi::class) +class CommandPropsTest { + private val problems = ArrayList() + private val registry = ComponentRegistry().registerBuiltins() + private val tree = NodeTree(registry) { problems += it } + private val events = ArrayList() + + /** The page's scope without a page: props and events from the node, dispatch recorded. */ + private fun scopeOf(node: UINode) = object : NodeScope { + override val node = node + @Suppress("UNCHECKED_CAST") + override fun get(key: String): T? = (node.props[key] ?: registry.schema(node.type)?.prop(key)?.default) as T? + override fun has(event: String) = node.events[event] == true + override fun dispatch(event: String, payload: String) { events += "$event $payload" } + @Composable override fun modifier(clickable: Boolean) = Modifier + @Composable override fun Children() {} + @Composable override fun RenderChild(child: UINode) {} + } + + @Test + fun textFieldSetTextClipsToTheLimitOfItsOwnFlush() = runComposeUiTest { + tree.apply("""[["c",1,"TextField"],["p",1,"maxLength",4],["p",1,"onChange",true],["i",0,1,0]]""") + val scope = scopeOf(tree.node(1)!!) + setContent { MaterialTheme { registry.Render("TextField", scope) } } + waitForIdle() + // one flush raises the limit and sets text longer than the old one + tree.apply("""[["p",1,"maxLength",10],["x",1,"setText",{"text":"123456"}]]""") + waitForIdle() + assertTrue(problems.isEmpty(), problems.toString()) + assertEquals(1, events.size, events.toString()) + assertTrue("123456" in events.single(), "clipped with the limit of an earlier composition: ${events.single()}") + } + + @Test + fun codeFieldSetTextTakesTheLengthOfItsOwnFlush() = runComposeUiTest { + tree.apply("""[["c",1,"CodeField"],["p",1,"length",4],["p",1,"onChange",true],["i",0,1,0]]""") + val scope = scopeOf(tree.node(1)!!) + setContent { MaterialTheme { registry.Render("CodeField", scope) } } + waitForIdle() + tree.apply("""[["p",1,"length",6],["x",1,"setText",{"text":"123456"}]]""") + waitForIdle() + assertTrue(problems.isEmpty(), problems.toString()) + assertEquals(1, events.size, events.toString()) + assertTrue("123456" in events.single(), "took the length of an earlier composition: ${events.single()}") + } +} diff --git a/docs/components.md b/docs/components.md index 816c70f..87b72d7 100644 --- a/docs/components.md +++ b/docs/components.md @@ -181,6 +181,8 @@ const [open, setOpen] = signal(false); 命令进节点的 `commands` 队列(不是 props),组件用 `scope.Commands { command -> … }` 在组合后按到达顺序消费,一次性。`LazyColumn` 的 `scrollTo` 与新增行同一 flush 到达时,行先进 children,命令随后执行,index 已经有效。 +**处理器要在执行时读 props**:命令在它那批 flush 提交后送达,但早于这批引起的重组。处理器若用组合期捕获的值(`val maxLength = scope.get(...)` 再闭包进去),同一批里改 prop 又发命令时就用了旧值。`TextField.setText` 裁剪、`CodeField.setText` 取位数都在处理器里现读 `scope.get`,自定义组件照此办。 + ## 5. `Placeholder` 未注册的类型渲染 `Placeholder`,同时 E5 上报。debug 构建(Android `FLAG_DEBUGGABLE`、iOS debug binary)画红框加类型名与 id;release 渲染零尺寸的空 `Box`。