Repository navigation
feat: 页面随导航条目存活——TinyUIPage 与内置组件改用 Compose retain - #45
Conversation
PingPong 注册流程触发:返回上一步,填好的内容不能没了。 - TinyUIPage:PageHost 由 retain 持有,离开组合只算不可见(摘掉 uriHandler / 剪贴板),保留它的存储放手时才 close - 内置组件:TextField 文本、CodeField 数字、Column / Row / LazyColumn 滚动位置 retain;焦点、下拉展开仍 remember - UpdatesPage:当前包与已加载页面 retain,回来的是离开时那一页、打开时那个包 - 宿主不提供保留存储时是 Compose 缺省存储(离开即丢),行为与以前一致;提供方式见 app-model §5(Nav3 decorator 写法) - 依赖 androidx.compose.runtime:runtime-retain 1.12.1(composeMultiplatform 1.12.2 底下的 runtime 同代) - sample 四个 tab 各给一个存储:切走再切回,表单与滚动位置都在(Android / iOS 模拟器验过) - 文档:app-model §5 重写;roadmap 记栈深回收与页面预载 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideThis PR adopts Compose Sequence diagram for retained page navigationsequenceDiagram
participant Host as NavigationHost
participant Store as RetainedValuesStore
participant Page as TinyUIPage
participant Engine as PageHost
Host->>Store: LocalRetainedValuesStoreProvider(entry)
Store->>Page: retain(PageHost)
Page->>Engine: startOnce()
Host->>Page: remove entry from composition
Page->>Engine: visible(false)
Page->>Engine: detach uriHandler and clipboard
Host->>Page: restore entry
Page->>Engine: visible(true)
Host->>Store: clearChild(entry)
Store->>Page: onRetired() or onUnused()
Page->>Engine: close()
Flow diagram for retained built-in component stateflowchart TD
Compose[Compose component]
UserState{User-created state?}
Retain[retain state]
Remember[remember state]
Restore[Restore when page returns]
Reset[Reset when recomposed]
Compose --> UserState
UserState -->|text, code, scroll| Retain
UserState -->|focus, open menu| Remember
Retain --> Restore
Remember --> Reset
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
There was a problem hiding this comment.
CodeSeer 审查
结论:批准(附 1 条建议)
摘要
本次改动引入 runtime-retain,使页面和内置组件状态可随宿主提供的导航条目存储存活,并在 sample 与 UpdatesPage 中接入了该存储。主要风险在于 TinyUIPage 将可变的页面配置作为 retain key:同一导航条目内发生重新挂载时,旧 PageHost 会被保留而非关闭。
整个 PR 至 b8cfb75 · gpt-5.6-terra
| ) { | ||
| val pageHost = remember(page, host, propsJson, sourceMaps) { | ||
| PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps) | ||
| val retained = retain(page, host, propsJson, sourceMaps) { |
There was a problem hiding this comment.
[建议] page、host、propsJson 或 sourceMaps 在同一导航条目内变化时,会生成新的 retain 值;旧的 RetainedPage 只是退出组合,仍被该条目的 store 持有,因此直到整个条目被 clearChild 前都不会触发 111/112 行的 close()。这在 UpdatesPage 的 E2/E6 回退中会发生:切换 bundle 后重新加载出的 page.module 会替换旧页面,失败页的引擎却继续占用资源。应为一次重新挂载显式释放旧的 retain child/PageHost,或约束并实现每个 retained navigation entry 内页面配置不可变,避免把重挂载产生的旧实例当作可返回页面保留。
There was a problem hiding this comment.
核实过 runtime-retain 1.12.1 源码,不成立:ManagedRetainedValuesStore.saveExitingValue 只在 isRetainingExitedValues(= isEnabled && !isContentComposed)时保留离开的值。条目在屏幕上(内容已组合)时,key 变化产生的旧值走 else 分支立即 onRetired → PageHost.close();只有整个条目离开组合期间离开的值才会被保留。条目重新进入组合时 onContentEnteredComposition 还会 purgeUnusedExitedValues,把没被取回的旧值关掉。所以 UpdatesPage 的 E2/E6 回退换包时,失败页的引擎当场关闭。
There was a problem hiding this comment.
Hey - I've found 6 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt" line_range="50" />
<code_context>
) {
- val pageHost = remember(page, host, propsJson, sourceMaps) {
- PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps)
+ val retained = retain(page, host, propsJson, sourceMaps) {
+ RetainedPage(PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps))
}
</code_context>
<issue_to_address>
**Hidden pages keep privileged work running**
When a retained page leaves composition while one of its JS timers or asynchronous callbacks is pending, `TinyUIPage` only sends `visible(false)` and leaves the retained `PageHost` scope alive; scheduled JS timers can still call `host.call` or make HTTP requests through the retained capability context, so a hidden page performs host actions after navigation.
Pause or cancel background callbacks and reject privileged host actions while the page is not visible, or explicitly document and enforce that retained pages may continue background work.
Also at `compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:68-70`.
</issue_to_address>
### Comment 2
<location path="compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt" line_range="69-70" />
<code_context>
+ onDispose {
+ // off screen but maybe kept: hidden, and nothing to open links or copy with until it is back
+ pageHost.visible(false)
+ pageHost.context.uriHandler = null
+ pageHost.context.clipboard = null
+ }
}
</code_context>
<issue_to_address>
**Off-screen calls use departed locals**
When a retained page leaves composition while its locals are page-scoped or become invalid and JS invokes a host capability before the page returns, the retained `PageHost` keeps those values in `PageContext.locals`, so its JS runtime passes disposed or stale objects to host capabilities and the call acts on stale state or fails.
Clear or replace `PageContext.locals` when the page leaves composition and refresh it when the page returns.
Also at `compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:51`.
</issue_to_address>
### Comment 3
<location path="updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt" line_range="46-49" />
<code_context>
+ val loadedState = retain(bundle, name) { mutableStateOf<Result<LoadedPage>?>(null) }
+ LaunchedEffect(loadedState) {
+ if (loadedState.value != null) return@LaunchedEffect
+ loadedState.value = try {
Result.success(bundle.page(name))
} catch (e: CancellationException) {
</code_context>
<issue_to_address>
**Load failures persist on return**
When a transient package-file read failure occurs while loading a page that remains in a retained navigation entry, `Bundle.page(name)` failures are stored in `loadedState` as `Result.failure`; when the same retained entry returns, the non-null state skips loading again and keeps showing the error even if the underlying read problem has cleared.
Do not retain failed load results indefinitely; provide a retry/reset path or reload failures when the entry is revisited.
</issue_to_address>
### Comment 4
<location path="compose/build.gradle.kts" line_range="36" />
<code_context>
commonMain.dependencies {
api(libs.quickjs.kmp)
implementation(compose.runtime)
+ implementation(libs.compose.runtime.retain)
implementation(compose.foundation)
implementation(libs.compose.material3)
</code_context>
<issue_to_address>
**Host integration cannot compile**
When an external host follows the documented retained-store integration without separately declaring `runtime-retain`, the documented Navigation 3 decorator calls `retainRetainedValuesStoreRegistry` and `LocalRetainedValuesStoreProvider`, but this `implementation` dependency is not exposed to consuming apps, so those imports are unresolved unless each host separately adds the artifact.
Expose `runtime-retain` as an `api` dependency, or explicitly document that host apps must declare it themselves.
</issue_to_address>
### Comment 5
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="236" />
<code_context>
fun clip(t: String) = clipTo(t, maxLength)
- var text by remember { mutableStateOf(clip(scope.get<String>("initialText") ?: "")) }
+ var text by retain { mutableStateOf(clip(scope.get<String>("initialText") ?: "")) }
var focused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
</code_context>
<issue_to_address>
**State accumulates after nodes leave composition**
When a retained page repeatedly removes stateful components or scrolls through many stateful lazy-list rows before its entry is popped, `retain` keeps component state in the entry store after its composables leave composition, so removed nodes and visited rows accumulate stale state and increase memory use until the entry is popped.
Remove retained component state when its node leaves composition instead of retaining it for the entry's entire lifetime.
Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:195`, `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:301`, `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:420`.
</issue_to_address>
### Comment 6
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="420" />
<code_context>
@Composable
private fun LazyColumnComponent(scope: NodeScope) {
- val state = rememberLazyListState()
+ val state = retain { LazyListState() }
val node = scope.node
scope.Commands { command ->
</code_context>
<issue_to_address>
**Returning to list triggers another page load**
When a `LazyColumn` has `onReachEnd`, is left while scrolled to the end, and is later composed again without its item count changing, `retain { LazyListState() }` restores the list at its previous end position, but `LazyColumnComponent` restarts its `LaunchedEffect` when composition returns and resets local `firedFor` to `-1`. The first snapshot therefore dispatches `onReachEnd` again even though the list length has not changed, causing an extra pagination request.
Keep the fired-for-end state across composition exits, or explicitly avoid treating the restored end position as a new arrival until the user scrolls away or the list grows.
</issue_to_address>Sourcery assessment
Approval pending. 6 findings to address first.
Blocking findings: compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:50, compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:70, updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt:49, compose/build.gradle.kts:36, compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:236, and 1 more
- LazyColumn:onReachEnd 已为哪个长度触发过随列表状态保留,停在底部切走再回来不再多拉一页 - UpdatesPage:只保留加载成功的页面,失败的回来时重试 - tinyui-compose 以 api 依赖带出 runtime-retain,宿主照文档写 decorator 不用另加 - app-model §5:被保留的页面照常在后台运行(计时器、http、host.call 不停,页面看 pageVisible());locals 应与导航条目同寿 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
CodeSeer 审查
结论:批准(附 1 条建议)
上轮意见:1 条,已处理 0 条,待处理 1 条
compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:50[建议]page、host、propsJson或sourceMaps在同一导航条目内变化时,会生成新的 retain 值;旧的 …
摘要
本次增量将 runtime-retain 以 api 暴露给宿主,并让 LazyColumn 的到尾触发状态随页面保留。UpdatesPage 对加载失败的保留结果改为在页面重新组合后重试,同时补充了相应的生命周期文档说明。基于给出的增量代码,未发现可确认的逻辑、资源或安全问题。
注册流程所需框架能力的第三部分(#43、#44 已合;本 PR 合后发 0.14.0)。返回上一步时表单内容不能丢:页面实例跟着导航条目活,与原生页一致,不让业务把草稿塞
store自救。做法:Compose 官方
retainandroidx.compose.runtime:runtime-retain(为「在返回栈里、此刻没被组合的导航页」设计)。缺省存储离开组合即丢,宿主不做任何事时行为与以前完全一样;宿主给每个导航条目一个保留存储(Nav3 是一个十几行的 decorator,写法在 app-model §5),条目出栈clearChild时页面才关。框架不依赖任何导航库。TinyUIPagePageHost由retain持有;离开组合 →visible(false)、摘掉uriHandler/ 剪贴板(期间答E_UNSUPPORTED);存储放手(onRetired/onUnused)才close()retain:TextField文本、CodeField数字、Column/Row/LazyColumn滚动位置;焦点、展开的下拉菜单仍rememberUpdatesPageretain:回来的是离开时那一页、打开时那个包;第一帧即组合出被保留的页面,否则存储会当它没被取回而放手runtime-retain1.12.1:composeMultiplatform 1.12.2 各端底下就是 androidx runtime 1.12.1,同代选型对比过 ViewModel 作用域:宿主没有按条目的 ViewModelStore 时会落到 Activity 级、页面泄漏;
retain缺省即旧行为,且框架 API 不变。验证
:compose:testAndroidHostTest67、:updates:testAndroidHostTest34、:sample:shared:testAndroidHostTest、apiCheck(API 无变化)TextField文本丢了(JS 状态在、组件remember的文本没了),由此把组件状态也改成retain文档
clearChild)、与页面预载的关系🤖 Generated with Claude Code
Summary by Sourcery
Retain page instances and user-facing UI state with their navigation entries so returning to a page restores it as left.
New Features:
Bug Fixes:
Enhancements:
Build:
Documentation: