Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions compose/api/compose.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
}

Expand Down
22 changes: 17 additions & 5 deletions compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,26 @@ 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, 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
* - [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) {}
}
}

Expand Down
18 changes: 15 additions & 3 deletions compose/src/commonMain/kotlin/app/tinyui/PageHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Unit>()
/** The last [visible]; one that arrives before the mount is applied right after it. */
@Volatile private var wantVisible = true

Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -203,12 +208,19 @@ 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()
Comment thread
codeseerbot[bot] marked this conversation as resolved.
Resolved
// 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 {
Expand Down Expand Up @@ -438,8 +450,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)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
"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) } } }
Expand Down
45 changes: 44 additions & 1 deletion compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ 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]); },
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";
Expand All @@ -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<String>()
private val navigated = mutableListOf<String>()
private val store = tinyui.store

private fun host(props: String = "{}", maps: SourceMaps = SourceMaps.EMPTY, app: TinyUIHost = tinyui) =
Expand Down Expand Up @@ -160,6 +166,43 @@ 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 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 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()
Expand Down
30 changes: 26 additions & 4 deletions docs/native-api.md
Original file line number Diff line number Diff line change
@@ -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`

Expand Down Expand Up @@ -49,12 +49,34 @@ 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]` 取回。**要在调用里同步取**:`locals` 跟着页面最近一次组合走,切线程之后再取,可能已是别的值:

```kotlin
class NavEntry(val stack: MutableList<NavEntry>) { var pageHost: PageHost? = null } // 宿主自己的类型
val NavEntryLocal = PageLocal<NavEntry>()

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 // 同步取,再切到主线程动栈
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" }`。那一页还在挂载(如被回收后按 props 重建)时结果会等它挂载完再送达,不会丢
- `push` 从不在栈顶的页面发起时由宿主定,通常忽略并经 `PageSink` 上报:被盖住的页面不该把用户带去别处
- 缺省 `Navigator.None`(调用无效果)跳原生页的路由名由 `Navigator` 解释,不进快照;`push` 是 J4,页面拿不到结果,认不出的名字由 `Navigator` 忽略并经 `PageSink` 上报。

## 4. `store`

Expand Down
Loading