From 1d9e62b1c161822665175dd2d50bbe30ac862d91 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 28 Oct 2026 19:51:54 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat!:=20Navigator=20=E7=9A=84=20push=20/?= =?UTF-8?q?=20pop=20=E5=B8=A6=E4=B8=8A=E8=B0=83=E7=94=A8=E6=96=B9=20PageCo?= =?UTF-8?q?ntext=EF=BC=8Cpop=20=E5=AE=9A=E4=B8=BA=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 留在返回栈里的页面照常运行,页面也可能分在几个栈里,宿主只凭「栈顶」会弹错页、动错栈。 两个方法都加 from: PageContext(与 HostCapability、AnalyticsSink、LinkOpener 一致), 宿主经挂载时的 locals 认出调用方自己的条目;pop 关闭调用方并把结果交给它下面那一页, 非栈顶页面发起的 push 由宿主决定,通常忽略。 Co-Authored-By: Claude Opus 5.5 --- compose/api/compose.klib.api | 8 +++---- .../kotlin/app/tinyui/HostInterfaces.kt | 21 ++++++++++++---- .../commonMain/kotlin/app/tinyui/PageHost.kt | 4 ++-- .../kotlin/app/tinyui/PageHostTest.kt | 21 ++++++++++++++++ docs/native-api.md | 24 +++++++++++++++---- 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index 73cd7ba..3ea50f2 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -67,12 +67,12 @@ abstract interface app.tinyui.schema/NodeScope { // app.tinyui.schema/NodeScope| } abstract interface app.tinyui/Navigator { // app.tinyui/Navigator|null[0] - abstract fun pop(kotlin/String?) // app.tinyui/Navigator.pop|pop(kotlin.String?){}[0] - abstract fun push(kotlin/String, kotlin/String) // app.tinyui/Navigator.push|push(kotlin.String;kotlin.String){}[0] + abstract fun pop(kotlin/String?, app.tinyui/PageContext) // app.tinyui/Navigator.pop|pop(kotlin.String?;app.tinyui.PageContext){}[0] + abstract fun push(kotlin/String, kotlin/String, app.tinyui/PageContext) // app.tinyui/Navigator.push|push(kotlin.String;kotlin.String;app.tinyui.PageContext){}[0] final object None : app.tinyui/Navigator { // app.tinyui/Navigator.None|null[0] - final fun pop(kotlin/String?) // app.tinyui/Navigator.None.pop|pop(kotlin.String?){}[0] - final fun push(kotlin/String, kotlin/String) // app.tinyui/Navigator.None.push|push(kotlin.String;kotlin.String){}[0] + final fun pop(kotlin/String?, app.tinyui/PageContext) // app.tinyui/Navigator.None.pop|pop(kotlin.String?;app.tinyui.PageContext){}[0] + final fun push(kotlin/String, kotlin/String, app.tinyui/PageContext) // app.tinyui/Navigator.None.push|push(kotlin.String;kotlin.String;app.tinyui.PageContext){}[0] } } diff --git a/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt b/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt index d6eaa98..f0f05dd 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt @@ -14,14 +14,25 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import kotlinx.serialization.serializer -/** J4 `navigation.push` / `navigation.pop`; results travel back as K5 `navigation.result` (docs/app-model.md). */ +/** + * J4 `navigation.push` / `navigation.pop`; results travel back as K5 `navigation.result` (docs/native-api.md §3). + * + * [from] is the mount that called. A page kept in the back stack keeps running, and pages may live in several + * stacks, so the host acts on the caller, not on whatever is on top: it mounts each page with a [PageLocal] naming + * its own entry and reads it back here. + * + * - [pop] closes the caller, wherever it is in its stack, and hands [resultJson] (when there is one) to the page + * below it, as Android's `finish()` or SwiftUI's `dismiss` do + * - [push] from a page that is not on top is the host's call; ignoring it and reporting through [PageSink] is the + * usual choice, a covered page should not take the user elsewhere + */ interface Navigator { - fun push(page: String, paramsJson: String) - fun pop(resultJson: String?) + fun push(page: String, paramsJson: String, from: PageContext) + fun pop(resultJson: String?, from: PageContext) object None : Navigator { - override fun push(page: String, paramsJson: String) {} - override fun pop(resultJson: String?) {} + override fun push(page: String, paramsJson: String, from: PageContext) {} + override fun pop(resultJson: String?, from: PageContext) {} } } diff --git a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt index 4856e44..d989dde 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt @@ -438,8 +438,8 @@ class PageHost internal constructor( val a = args(argsJson) when (name) { "timer.cancel" -> a["cbId"]?.jsonPrimitive?.intOrNull?.let { timers.remove(it)?.cancel() } - "navigation.push" -> host.navigator.push(a.str("page") ?: return, a["params"]?.toString() ?: "{}") - "navigation.pop" -> host.navigator.pop(a["result"]?.toString()) + "navigation.push" -> host.navigator.push(a.str("page") ?: return, a["params"]?.toString() ?: "{}", context) + "navigation.pop" -> host.navigator.pop(a["result"]?.toString(), context) "store.set" -> { val key = a.str("key") ?: return; host.store.setJson(key, a.str("value") ?: "null") } // one host listener per key / topic; JS fans out to its own handlers "store.subscribe" -> { val key = a.str("key") ?: return; subscriptions.getOrPut("store:$key") { host.store.observe(key) { v -> storeChanged(key, v) } } } diff --git a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt index 12f54b8..6bdb071 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt @@ -63,6 +63,7 @@ class PageHostTest { if (event === "onI18nSubscribe") { __host_send("i18n.subscribe", "{}"); patches.push(["p",1,"text","subscribed"]); } if (event === "onTagged") { __host_call("http.request", 21, JSON.stringify({ channel: "app", method: "POST", url: "/code", body: {}, tags: { captchaScene: "signup" } })); __host_call("http.request", 22, JSON.stringify({ channel: "app", method: "GET", url: "/code", tags: { n: 1 } })); } if (event === "onEncrypt") { __host_call("crypto.rsaEncrypt", 23, JSON.stringify({ text: "s3cret", publicKey: payloadJson })); __host_call("crypto.rsaEncrypt", 24, JSON.stringify({ text: "s3cret", publicKey: "bm9wZQ==" })); } + if (event === "onNavigate") { __host_send("navigation.push", JSON.stringify({ page: "pages/detail", params: { id: 42 } })); __host_send("navigation.pop", JSON.stringify({ result: { saved: true } })); patches.push(["p",1,"text","navigated"]); } if (event === "onSeq") { seq.push(JSON.parse(payloadJson).i); patches.push(["p",1,"text","seq " + seq.length + " " + (seq.every((v, i) => v === i) ? "ordered" : "shuffled")]); } }, emit(topic, json) { emits++; patches.push(["p",1,"text", topic + " " + json + " #" + emits]); }, @@ -90,8 +91,13 @@ class PageHostTest { locale = MutableStateFlow("en"), timeZone = zoneFlow, analytics = AnalyticsSink { name, props, page -> tracked += "${page.name} $name $props" }, + navigator = object : Navigator { + override fun push(page: String, paramsJson: String, from: PageContext) { navigated += "push $page $paramsJson from ${from.name}/${from[Merchant]}" } + override fun pop(resultJson: String?, from: PageContext) { navigated += "pop $resultJson from ${from.name}/${from[Merchant]}" } + }, ).apply { store.setJson("cart", """{"n":2}""") } private val tracked = mutableListOf() + private val navigated = mutableListOf() private val store = tinyui.store private fun host(props: String = "{}", maps: SourceMaps = SourceMaps.EMPTY, app: TinyUIHost = tinyui) = @@ -160,6 +166,21 @@ class PageHostTest { host.close() } + @Test + fun navigationTellsTheNavigatorWhichMountCalled() = runTest { + val host = host() + host.start() + host.await { host.tree.root.children.isNotEmpty() } + host.dispatch(2, "onNavigate", "{}") + host.await { host.tree.node(1)!!.props["text"] == "navigated" } + // the locals the page was mounted with come back: that is how a host finds the caller's own entry + assertEquals( + listOf("""push pages/detail {"id":42} from pages/counter/acme""", """pop {"saved":true} from pages/counter/acme"""), + navigated, + ) + host.close() + } + @Test fun rsaEncryptGoesThroughTheBridge() = runTest { val host = host() diff --git a/docs/native-api.md b/docs/native-api.md index 6933dc9..a57bc04 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -1,6 +1,6 @@ # `tinyui-native` 与宿主接口 -- 状态:已定(2026-09-16);2026-10-28 加 `http` 请求 `tags`(§6)与 `crypto.rsaEncrypt`(§16);2026-09-25 按 [ADR-007](./adr-007-host-boundary.md) 重写:框架自己实现网络、存储、i18n、toast / 对话框、打开链接、埋点门面与会话,宿主只提供标准接口的实现;删 `HostServices`、`config.get`、宿主 `I18n` / `Config` / `HttpClient` +- 状态:已定(2026-09-16);2026-10-28 加 `http` 请求 `tags`(§6)与 `crypto.rsaEncrypt`(§16),同日 `Navigator` 带调用方 `from`、`pop` 定为关闭调用方(§3);2026-09-25 按 [ADR-007](./adr-007-host-boundary.md) 重写:框架自己实现网络、存储、i18n、toast / 对话框、打开链接、埋点门面与会话,宿主只提供标准接口的实现;删 `HostServices`、`config.get`、宿主 `I18n` / `Config` / `HttpClient` - 来源:ADR-002 §3.1(J2 白名单、J3 / J4)、[app-model.md](./app-model.md)(路由、store、会话、事件)、ADR-004 §3.1(连续事件阈值)、ADR-007(职责边界) - 两侧:JS 的 `tinyui-native` 是 `__host_*` 的类型化封装;Kotlin 侧宿主给页面的东西只有 App 级的 `TinyUIHost`(§1),启动时建一次,交给每个 `TinyUIPage` @@ -49,12 +49,28 @@ TinyUIPage(module, host, propsJson = "{}", locals = emptyList(), modifier = …) ## 3. `navigation` ```ts -navigation.push("subscription/detail", { id: 42 }); // J4 → Navigator.push(page, paramsJson);页面名含包名 -navigation.pop({ saved: true }); // J4 → Navigator.pop(resultJson) +navigation.push("subscription/detail", { id: 42 }); // J4 → Navigator.push(page, paramsJson, from);页面名含包名 +navigation.pop({ saved: true }); // J4 → Navigator.pop(resultJson, from):关闭本页 navigation.onResult((result, from) => …); // 渲染期调用;K5 topic navigation.result ``` -宿主实现 `Navigator` 接口,交给 `TinyUIHost`;把结果送回上一页时,宿主持有该页的 `PageHost`(`TinyUIPage` 的 `onHost` 回调拿到),调 `host.emit("navigation.result", json)`,`json` 形如 `{ "result": …, "from": "subscription/detail" }`。缺省 `Navigator.None`(调用无效果)。跳原生页的路由名由 `Navigator` 解释,不进快照;`push` 是 J4,页面拿不到结果,认不出的名字由 `Navigator` 忽略并经 `PageSink` 上报。 +宿主实现 `Navigator` 接口,交给 `TinyUIHost`。两个方法都带 `from: PageContext`,即发起调用的那次挂载:留在返回栈里的页面照常运行([app-model.md](./app-model.md) §5),页面也可能分在几个栈里(各 tab、弹层),所以宿主**按调用方操作,不按栈顶**。宿主挂载页面时经 `locals` 给它一个指向自己导航条目的句柄,在这里用 `from[local]` 取回: + +```kotlin +val NavEntryLocal = PageLocal() // 宿主自己的类型:所在的栈 + 本页的条目 + +TinyUIPage(page, host, props, locals = listOf(NavEntryLocal provides entry), onHost = { entry.pageHost = it }) + +override fun pop(resultJson: String?, from: PageContext) { + val entry = from[NavEntryLocal] ?: return + val below = entry.stack.remove(entry) // 关的是调用方,哪怕它不在栈顶 + if (resultJson != null) below?.pageHost?.emit("navigation.result", """{"result":$resultJson,"from":"${from.name}"}""") +} +``` + +- `pop` = 关闭调用方自己(同 Android `finish()`、SwiftUI `dismiss`),结果交给它下面那一页:宿主持有那一页的 `PageHost`(`TinyUIPage` 的 `onHost` 回调拿到),调 `emit("navigation.result", json)`,`json` 形如 `{ "result": …, "from": "subscription/detail" }` +- `push` 从不在栈顶的页面发起时由宿主定,通常忽略并经 `PageSink` 上报:被盖住的页面不该把用户带去别处 +- 缺省 `Navigator.None`(调用无效果)跳原生页的路由名由 `Navigator` 解释,不进快照;`push` 是 J4,页面拿不到结果,认不出的名字由 `Navigator` 忽略并经 `PageSink` 上报。 ## 4. `store` From 658364b2f717ee8d57e3f55ab1bf1dc43ec4c037 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 28 Oct 2026 20:04:45 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E6=8C=82=E8=BD=BD=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=89=8D=E5=88=B0=E8=BE=BE=E7=9A=84=20K=20=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E7=AD=89=E6=8C=82=E8=BD=BD=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=B8=A2?= =?UTF-8?q?=E5=BC=83=EF=BC=9BNavigator=20=E6=96=87=E6=A1=A3=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E4=B8=8E=E5=90=8C=E6=AD=A5=E5=8F=96=20locals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PageHost 的 entry() 遇到还没挂载的页面直接返回,宿主在页面被回收重建时 emit 的 navigation.result 会丢 (与 app-model.md「被回收重建后照样收到」不符);现在先等挂载结束,挂载失败或页面关闭才放弃 - native-api.md §3 的 pop 示例改成能编译的写法,并写明 from[local] 要在调用里同步取(locals 跟着最近一次组合) Co-Authored-By: Claude Opus 5.5 --- .../kotlin/app/tinyui/HostInterfaces.kt | 3 ++- .../commonMain/kotlin/app/tinyui/PageHost.kt | 12 +++++++++++- .../kotlin/app/tinyui/PageHostTest.kt | 10 ++++++++++ docs/native-api.md | 18 ++++++++++++------ 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt b/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt index f0f05dd..a106070 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt @@ -19,7 +19,8 @@ import kotlinx.serialization.serializer * * [from] is the mount that called. A page kept in the back stack keeps running, and pages may live in several * stacks, so the host acts on the caller, not on whatever is on top: it mounts each page with a [PageLocal] naming - * its own entry and reads it back here. + * its own entry and reads it back here, within the call: `locals` follow the page's latest composition, and a read + * after hopping threads may see a newer value. * * - [pop] closes the caller, wherever it is in its stack, and hands [resultJson] (when there is one) to the page * below it, as Android's `finish()` or SwiftUI's `dismiss` do diff --git a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt index d989dde..67d6a28 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.concurrent.Volatile +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -139,6 +140,8 @@ class PageHost internal constructor( jsThread, ) private var entries: Entries? = null + /** Settles once the mount is over, whether it worked or not: K entries that arrive earlier wait for it instead of being dropped. */ + private val mountSettled = CompletableDeferred() /** The last [visible]; one that arrives before the mount is applied right after it. */ @Volatile private var wantVisible = true @@ -175,6 +178,8 @@ class PageHost internal constructor( fail("E6", IllegalStateException("page load exceeded ${entryTimeoutMs} ms and was interrupted")) } catch (t: Throwable) { fail("E6", t) + } finally { + mountSettled.complete(Unit) } } } @@ -203,12 +208,17 @@ class PageHost internal constructor( private fun reject(cbId: Int, code: String, message: String, details: JsonObject?) = entry("reject", JsValue.Num(cbId), JsValue.Str(JsonObject((details ?: JsonObject(emptyMap())) + mapOf("code" to JsonPrimitive(code), "message" to JsonPrimitive(message))).toString())) - /** K5. The host calls this for `navigation.result`; bus topics arrive through the page's own subscriptions. */ + /** + * K5. The host calls this for `navigation.result`; bus topics arrive through the page's own subscriptions. + * Before the page has mounted it waits for the mount; a page that failed or closed meanwhile drops it. + */ fun emit(topic: String, payloadJson: String) = entry("emit", JsValue.Str(topic), JsValue.Str(payloadJson)) private fun entry(name: String, vararg args: JsValue, before: () -> Unit = {}, skipWhen: () -> Boolean = { false }) { if (failure != null) return scope.launch(entryLane) { + // a host emit (navigation.result to a page being remounted) can beat the mount to the engine lock + mountSettled.await() try { withTimeout(entryTimeoutMs) { runtime.withEngine { diff --git a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt index 6bdb071..c8f4a1c 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt @@ -181,6 +181,16 @@ class PageHostTest { host.close() } + @Test + fun aResultEmittedBeforeTheMountArrivesAfterIt() = runTest { + val host = host() + // a page remounted from its props gets navigation.result while it is still loading + host.emit("navigation.result", """{"result":{"saved":true},"from":"pages/detail"}""") + host.start() + host.await { host.tree.node(1)?.props?.get("text") == """navigation.result {"result":{"saved":true},"from":"pages/detail"} #1""" } + host.close() + } + @Test fun rsaEncryptGoesThroughTheBridge() = runTest { val host = host() diff --git a/docs/native-api.md b/docs/native-api.md index a57bc04..941dbaa 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -54,21 +54,27 @@ navigation.pop({ saved: true }); // J4 → Navigator.pop(resultJs navigation.onResult((result, from) => …); // 渲染期调用;K5 topic navigation.result ``` -宿主实现 `Navigator` 接口,交给 `TinyUIHost`。两个方法都带 `from: PageContext`,即发起调用的那次挂载:留在返回栈里的页面照常运行([app-model.md](./app-model.md) §5),页面也可能分在几个栈里(各 tab、弹层),所以宿主**按调用方操作,不按栈顶**。宿主挂载页面时经 `locals` 给它一个指向自己导航条目的句柄,在这里用 `from[local]` 取回: +宿主实现 `Navigator` 接口,交给 `TinyUIHost`。两个方法都带 `from: PageContext`,即发起调用的那次挂载:留在返回栈里的页面照常运行([app-model.md](./app-model.md) §5),页面也可能分在几个栈里(各 tab、弹层),所以宿主**按调用方操作,不按栈顶**。宿主挂载页面时经 `locals` 给它一个指向自己导航条目的句柄,在这里用 `from[local]` 取回。**要在调用里同步取**:`locals` 跟着页面最近一次组合走,切线程之后再取,可能已是别的值: ```kotlin -val NavEntryLocal = PageLocal() // 宿主自己的类型:所在的栈 + 本页的条目 +class NavEntry(val stack: MutableList) { var pageHost: PageHost? = null } // 宿主自己的类型 +val NavEntryLocal = PageLocal() TinyUIPage(page, host, props, locals = listOf(NavEntryLocal provides entry), onHost = { entry.pageHost = it }) override fun pop(resultJson: String?, from: PageContext) { - val entry = from[NavEntryLocal] ?: return - val below = entry.stack.remove(entry) // 关的是调用方,哪怕它不在栈顶 - if (resultJson != null) below?.pageHost?.emit("navigation.result", """{"result":$resultJson,"from":"${from.name}"}""") + val entry = from[NavEntryLocal] ?: return // 同步取,再切到主线程动栈 + mainScope.launch { + val index = entry.stack.indexOf(entry) + if (index <= 0) return@launch // 已出栈,或是栈底 + val below = entry.stack[index - 1] + entry.stack.removeAt(index) // 关的是调用方,哪怕它不在栈顶 + if (resultJson != null) below.pageHost?.emit("navigation.result", """{"result":$resultJson,"from":"${from.name}"}""") + } } ``` -- `pop` = 关闭调用方自己(同 Android `finish()`、SwiftUI `dismiss`),结果交给它下面那一页:宿主持有那一页的 `PageHost`(`TinyUIPage` 的 `onHost` 回调拿到),调 `emit("navigation.result", json)`,`json` 形如 `{ "result": …, "from": "subscription/detail" }` +- `pop` = 关闭调用方自己(同 Android `finish()`、SwiftUI `dismiss`),结果交给它下面那一页:宿主持有那一页的 `PageHost`(`TinyUIPage` 的 `onHost` 回调拿到),调 `emit("navigation.result", json)`,`json` 形如 `{ "result": …, "from": "subscription/detail" }`。那一页还在挂载(如被回收后按 props 重建)时结果会等它挂载完再送达,不会丢 - `push` 从不在栈顶的页面发起时由宿主定,通常忽略并经 `PageSink` 上报:被盖住的页面不该把用户带去别处 - 缺省 `Navigator.None`(调用无效果)跳原生页的路由名由 `Navigator` 解释,不进快照;`push` 是 J4,页面拿不到结果,认不出的名字由 `Navigator` 忽略并经 `PageSink` 上报。 From c042f6fdc7ad2e9a74a58145a1cd6c50598d94c5 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 28 Oct 2026 21:19:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E7=AD=89=E6=8C=82=E8=BD=BD=E7=9A=84?= =?UTF-8?q?=20K=20=E5=85=A5=E5=8F=A3=E9=86=92=E6=9D=A5=E5=90=8E=E5=86=8D?= =?UTF-8?q?=E6=9F=A5=E4=B8=80=E6=AC=A1=20failure=EF=BC=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=A1=B5=E9=9D=A2=E4=B8=8D=E5=86=8D=E8=BF=9B=E5=BC=95?= =?UTF-8?q?=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mount 在 entries 赋值之后才抛时,先到的 emit 等挂载结束后仍会调失败页面的引擎(CodeSeer 审查意见)。 测试用桩里只认 probe topic 的 emit 回调 navigation.pop 作为可观察副作用:不修时 pop 到达 Navigator。 Co-Authored-By: Claude Opus 5.5 --- .../src/commonMain/kotlin/app/tinyui/PageHost.kt | 2 ++ .../commonTest/kotlin/app/tinyui/PageHostTest.kt | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt index 67d6a28..4b4acaf 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt @@ -219,6 +219,8 @@ class PageHost internal constructor( scope.launch(entryLane) { // a host emit (navigation.result to a page being remounted) can beat the mount to the engine lock mountSettled.await() + // the mount may have failed after entries were set: a failed page takes no more entries + if (failure != null) return@launch try { withTimeout(entryTimeoutMs) { runtime.withEngine { diff --git a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt index c8f4a1c..8ab9825 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt @@ -66,7 +66,7 @@ class PageHostTest { if (event === "onNavigate") { __host_send("navigation.push", JSON.stringify({ page: "pages/detail", params: { id: 42 } })); __host_send("navigation.pop", JSON.stringify({ result: { saved: true } })); patches.push(["p",1,"text","navigated"]); } if (event === "onSeq") { seq.push(JSON.parse(payloadJson).i); patches.push(["p",1,"text","seq " + seq.length + " " + (seq.every((v, i) => v === i) ? "ordered" : "shuffled")]); } }, - emit(topic, json) { emits++; patches.push(["p",1,"text", topic + " " + json + " #" + emits]); }, + emit(topic, json) { if (topic === "probe") __host_send("navigation.pop", JSON.stringify({ result: "probe" })); emits++; patches.push(["p",1,"text", topic + " " + json + " #" + emits]); }, flush() { if (count > 2) throw new Error("render exploded"); if (patches.length) { __host_apply(JSON.stringify(patches)); patches = []; } }, }; export const VERSION = "stub"; @@ -191,6 +191,18 @@ class PageHostTest { host.close() } + @Test + fun aPageWhoseMountFailedTakesNoWaitingEntry() = runTest { + val host = host("not json") // the stub's mount parses props: it throws once entries are already set + host.emit("probe", "{}") // the stub answers it with navigation.pop: visible if it reaches the engine + host.start() + host.await { host.failure != null } + delay(200) + assertEquals("E6", host.failure!!.kind) + assertEquals(emptyList(), navigated) + host.close() + } + @Test fun rsaEncryptGoesThroughTheBridge() = runTest { val host = host()